ai

HiveJournal Feature Index

Master catalog of every feature in HiveJournal. Start here. Each entry links to the key files an AI assistant or contributor needs to find.

Looking for "which features exist on which surface (web / mobile / chat / extension)?" — see the cross-cut grid in docs/reference/FEATURES_BY_INTERFACE.md.


Core Journaling

Journal Entries

Rich-text journal entries with mood, tags, background images, font customization, and visibility controls.

  • Frontend: apps/frontend/src/app/dashboard/entries/new/page.tsx · [id]/page.tsx
  • Backend: apps/backend/src/routes/journal.ts
  • Migrations: 001_initial_schema.sql, 004_add_visibility_options.sql, 024_add_entry_sharing.sql
  • Routes: /dashboard/entries/new, /dashboard/entries/[id]
  • Inline quick-meta edit (no edit mode): components/entries/EntryQuickMeta.tsx exports <NotebookQuickSwitch> (⇄ button next to the header notebook chip → searchable notebook popover incl. "No notebook"; dashed "+ Notebook" when unfiled) and <TagsQuickEdit> (Edit/+Add tags → removable chips + type-and-Enter/comma input, Save/Cancel). Both persist via the existing partial-update body on PUT /api/journal/:id ({ notebook_id } / { tags }), patch the detail page's local entry state, and invalidateDashboardEntryCache(). Owner-gated (canEditMeta) and only rendered for plain notes (isPlainEntry — not satisfaction/sleep/action or Patent/Experiment docs, whose tags are structured classification).

Linked / "merged" notes

Users can link adjacent note cards so they render as one longer merged note on the dashboard + stream, while the originals stay separate rows underneath (non-destructive). Opening a merged note offers a Merged / Individual toggle to read it as one document or manage each note separately.

  • Model: journal_entries.merge_group_id UUID (migration 382_journal_entry_merge_group.sql, partial-indexed) — entries sharing a non-null group id are one merged note. Ordering within a group is by created_at.
  • Backend (routes/journal.ts): POST /api/journal/:id/merge { target_id } links two entries (coalesces existing groups, mints a uuid otherwise, owner-scoped); DELETE /api/journal/:id/merge unlinks one entry and dissolves a now-orphaned group of one; GET /api/journal/:id hydrates entry.merge_group (all members, oldest-first, ≥2) for the detail views.
  • Dashboard (dashboard/page.tsx): contiguous same-group entries collapse into one card (anchor's content + each linked note beneath a hairline divider + a 🔗N badge); a hover "🔗 Link" affordance appears between any two adjacent own-regular cards. Stream (dashboard/stream/page.tsx) collapses a group to one floating card with a 🔗N count. Detail page (entries/[id]/page.tsx) renders the Merged/Individual toggle as the first branch of the content render.

Journal Entry Attachments (Phase 1 — single image)

Per-entry image attachments stored in private Supabase Storage with sharp-generated webp thumbnails. v1 = 1 image per entry; schema supports multi-attach + multi-kind (PDF/doc) from day 1. First attachment doubles as the OG image for shared entries.

  • Schema: supabase/migrations/271_journal_entry_attachments.sqljournal_entry_attachments (id, entry_id, user_id, kind, storage_path, original_filename, mime_type, file_size_bytes, image_width, image_height, thumbnail_generated_at, position, created_at). Strict owner-only RLS. Position-ordered indexes for the gallery render + the OG-image lookup.
  • Storage bucket: journal-entry-attachments — private (signed URLs only, ~1h TTL). Path: <user_id>/<entry_id>/<attachment_id>.<ext>; thumbnails at <storage_path>.thumb.webp. Bucket is created out-of-band via Supabase dashboard (one-time operator setup); migration 271 deliberately doesn't auto-create it because Supabase Storage buckets aren't managed by SQL migrations.
  • Service: apps/backend/src/services/journal-entry-attachments.tscreateAttachment() (upload + sharp thumbnail + DB row insert; tolerates sharp failures by skipping thumb and falling back to original), deleteAttachment() (DB row + storage cleanup), listAttachmentsForEntry() (signed URLs for both original + thumbnail), getFirstAttachmentForSharedEntry() (used by the OG image route — gates on share expiry).
  • Routes: POST /api/journal/entries/:id/attachments (multipart upload, owner-only, 10MB cap, image MIME whitelist) · DELETE /api/journal/entries/:id/attachments/:attachmentId · GET /api/journal/entries/:id/attachments. The journal-share endpoint GET /api/journal/share/:shareId now also returns first_attachment: { url, thumbnail_url, mime_type, ... } | null.
  • Frontend component: apps/frontend/src/components/entries/EntryAttachments.tsx — declarative canEdit prop toggles between picker-with-remove (owner) and gallery-only (read-only). Click thumbnail → lightbox. Mounted at the bottom of the entry detail page just above the highlights section.
  • OG image override: api/og/[shareId]/route.tsx — if the shared entry has a first attachment, the OG route 302-redirects to its signed URL instead of generating a synthetic ImageResponse. Social crawlers cache the image bytes within seconds, so the ~1h TTL is fine in practice.
  • Tunable constants: MAX_ATTACHMENT_BYTES (10MB), MAX_ATTACHMENTS_PER_ENTRY (1 for Phase 1), ALLOWED_MIME_TYPES (jpeg/png/webp/gif/heic/heif). Phase 2 (multi-image) is just MAX_ATTACHMENTS_PER_ENTRY = N; Phase 3 (PDF/doc) appends to ALLOWED_MIME_TYPES + a non-image render path in the gallery component.

Entries containing a URL show a rich preview card (Open Graph image + title + site) beneath the body, like an iMessage/Slack unfurl. Default ON, per-user toggle (profiles.preferences.enable_link_previews, Settings → Link previews).

  • Service: apps/backend/src/services/link-preview.tsgetLinkPreview(url) fetches the page's <meta> OG tags (regex over the first ~256KB, no HTML-parser dep) and caches the result in link_previews (migration 416, keyed by URL, 30-day TTL; caches empty/error too so failures don't re-hammer). SSRF guard assertPublicUrl: http(s) + standard ports only, blocks literal/loopback/private/link-local/metadata IPs (incl. 169.254.169.254) AND re-validates DNS-resolved IPs + every redirect hop (manual redirect follow). Pure cores ipIsBlocked + extractMeta are unit-tested (tests/link-preview.test.ts).
  • Route: GET /api/link-preview?url=… (auth-gated so it's not an open SSRF proxy) → { preview: { title, description, image_url, site_name } | null }; returns null (not an error) when there's nothing to show.
  • Frontend: apps/frontend/src/components/entries/EntryLinkPreviews.tsx — extracts up to 4 URLs from the entry body, renders a self-fetching card each (hotlinks the OG image; per-card ✕ to hide; renders nothing if no preview). Mounted under the entry body in the detail page, gated on the preference. Settings toggle in settings/page.tsx (LinkPreviewsPreferencesSection). Note: some links (apple.news, paywalled, JS-only) expose no OG tags → no card.

lovio — Time-locked voice capsules (v1 foundation)

Side-feature of HiveJournal: write a journal entry today, flag it for lovio, pick a recipient and a future date, the system seals a capsule + renders the audio in your own cloned voice + delivers on the date you chose (even decades out). v1 ships date-trigger only; unlock_trigger is an enum from day 1 so adding milestone- and death-trigger later is plumbing not architecture.

Marketing surface: /lovio — hero + 3-step how-it-works + use-cases + trust commitments + waitlist signup. Public waitlist component: LovioWaitlistForm. Hero CTA now also links to /lovio/demo — a standalone preview page that mirrors the real /lovio/unlock/[token] flow with hardcoded sample content (sender, recipient, intro, four sample entries spanning seventeen years) so visitors can feel what receiving a capsule is like without signup. Conversion CTAs at the bottom route to the waitlist + dashboard. Voice-cloning trust posture lives at /lovio/ethics — public, journalist-quotable page articulating the four commitments (own-voice-only, recipient-consent-at-delivery, no-post-mortem-surprises, samples-stay-yours) the Day 21 waitlist drip email also surfaces. Updating these commitments requires updating BOTH this page AND the drip template since the email tells recipients "we'll write to you here first if anything changes" — the two surfaces must stay in sync.

Schema (migration 213):

  • lovio_user_voices — per-user voice clone. Stores ElevenLabs voice_id, consent_audio_url (recorded consent statement kept as proof), sample_audio_url (clean reading we cloned from), preview_audio_url. Partial unique index enforces one active clone per user (retired clones survive as soft-retired rows so historical capsules stay attributable).
  • lovio_capsules — one planned future delivery. unlock_trigger{date, milestone, death} with CHECK constraints ensuring the right date column is populated for each trigger type. Voice snapshot fields (voice_id, voice_label, audio_url, audio_duration_seconds, audio_rendered_at) are captured at seal time — retiring the voice clone after seal doesn't break the delivery. Partial indexes on unlock_at and unlock_milestone_date make the delivery cron query cheap. Lifecycle columns: sealed_at (point of no return), delivery_token (one-time recipient link), unlocked_at, delivery_email_sent_at, revoked_at.
  • lovio_capsule_entries — many-to-many: which journal entries live inside which capsule, ordered by position.
  • lovio_waitlist — public signup table (anonymous + authenticated). Single opt-in, dedupes by lowercased email, resurrects unsubscribed rows.
  • journal_entries.lovio_flagged_at — lightweight column for the "I want this in a future capsule someday" pile. Lets users flag-as-they-write without committing to a recipient or date.

RLS: owners only on lovio_user_voices / lovio_capsules / lovio_capsule_entries (via capsule join). lovio_waitlist allows anonymous + authenticated inserts; reads are super-admin only via service role.

Service: apps/backend/src/services/lovio.tsjoinLovioWaitlist, listWaitlist, createCapsule, listUserCapsules, getCapsule, addEntryToCapsule, removeEntryFromCapsule, listCapsuleEntries, deleteCapsule, setEntryLovioFlag, listFlaggedEntries. createCapsule enforces unlock_at >= now() + 1h to prevent accidental immediate-delivery + trims/clamps text fields server-side.

Routes (apps/backend/src/routes/lovio.ts, mounted at /api/lovio):

  • POST /waitlist — public; returns { status: 'created' | 'already_signed_up' | 'resubscribed' }
  • GET /waitlist — super-admin only
  • GET /capsules / POST /capsules / GET /capsules/:id / DELETE /capsules/:id — owner-only CRUD
  • POST /capsules/:id/entries / DELETE /capsules/:id/entries/:entryId — add/remove entries; rejects when sealed
  • PUT /entries/:id/flag / GET /flagged-entries — per-entry lovio flag

Owner-side capsule builder (shipped):

  • /dashboard/lovio — list view grouped by status (drafts / sealed / delivered + revoked), plus an inline new-capsule form (title, recipient name + email, relationship, unlock datetime, intro message). Datetime input enforces min of "now + 1h" client-side; backend re-validates. Form posts to POST /api/lovio/capsules and redirects to the new capsule's detail page.
  • /dashboard/lovio/[id] — per-capsule editor showing metadata, the entries currently inside (with remove button on drafts), and an entry-picker drawer triggered by "+ Add entry" that fetches GET /api/journal?limit=30 and renders each entry with a one-tap add button. Already-added entries render dimmed + non-clickable to prevent duplicates. Drafts also expose a "Delete draft" button. Sealed capsules hide all mutation surfaces.
  • Nav entry: added under Journal → Lovio in FeatureMenu.tsx with badge: 'New'.

Voice cloning (shipped):

  • Service: apps/backend/src/services/lovio-voice.ts wraps ElevenLabs Instant Voice Cloning. createVoiceClone({ userId, consentBuffer, sampleBuffer, ... }) orchestrates: (1) POST https://api.elevenlabs.io/v1/voices/add with a multipart form containing the sample audio → returns voice_id; (2) insert lovio_user_voices row with the new id; (3) upload both consent + sample audio to the shared season-assets storage bucket at lovio/<user_id>/voices/<row_id>/{consent,sample}.<ext> and patch the row with public URLs; (4) best-effort preview render — call POST /v1/text-to-speech/<voice_id> with eleven_multilingual_v2, upload the resulting MP3 as preview.mp3, store its URL on the row. Rollback on storage failure (delete the row; ElevenLabs voice persists upstream for later pruning). Active-uniqueness enforced by the partial unique index from migration 213.
  • Routes (mounted at /api/lovio):
    • GET /voice/consent-statement (public) — returns the fixed consent text the user must read aloud (single source of truth)
    • GET /voice (auth) — returns { voice: UserVoiceRecord | null }
    • POST /voice/setup (auth, multipart) — accepts consent + sample audio files (≤25MB each via multer memoryStorage) + optional voice_label; calls createVoiceClone
    • POST /voice/retire (auth) — soft-retires the active clone
  • Frontend: /dashboard/lovio/voice-setup — two-step browser MediaRecorder UI. Custom useAudioRecorder hook handles getUserMedia + permission errors + per-recording duration timer + mime-type auto-pick (audio/webm;codecs=opusaudio/webmaudio/mp4). FormData upload via the existing apiRequest helper (which auto-skips Content-Type for FormData bodies so the browser-generated multipart boundary survives). Success state surfaces the preview audio inline. Existing-voice probe gates the recording UI behind a retire prompt to prevent double-cloning. Voice status strip mounted on /dashboard/lovio shows whether a clone is active and links to the setup page either way.

Super-admin overview at /dashboard/admin/lovio (shipped):

  • Service: services/lovio-admin.ts getLovioAdminOverview(). Pulls count: 'exact', head: true queries in parallel (no row payloads transferred for the counters), plus recent-30-day result sets for the activity feed. Returns voice counters (active / retired / last-30d), capsule states (total / drafts / sealed-queued / delivered / revoked / 30d-sealed / 30d-delivered / notebook-bound), waitlist (active / unsubscribed / last_signup_at), reminders (sent_last_30d / last_sent_at), and an interleaved recent_activity array (voice_cloned / capsule_sealed / capsule_delivered / reminder_sent / waitlist_signup — newest-first, top 25).
  • Route: GET /api/lovio/admin/overview (auth + super-admin via the existing requireSuperAdmin middleware in routes/lovio.ts).
  • Frontend: /dashboard/admin/lovio/page.tsx — four sections (Voice clones / Capsules / Waitlist + reminders / Recent activity) with color-tiered stat cards and an emoji-prefixed activity feed. Added to the admin menu in FeatureMenu.tsx as "lovio overview." Generated-at relative-time label shows freshness.

Per-entry "hear it in my voice" (shipped):

  • Migration: 216_journal_voice_audio.sql — adds voice_audio_url, voice_audio_rendered_at, voice_audio_voice_id, voice_audio_content_hash to journal_entries so per-entry audio is cached on the row keyed by a content+voice hash. Re-rendering is free + instant; entry edits + voice retire/re-clone invalidate automatically.
  • Service: services/lovio-entry-voice.ts. renderEntryInVoice({ entryId, userId, force }) validates ownership + active voice, builds the script (<title>.\n\n<content>), checks the hash against the cached one (returns cached URL on hit unless force), otherwise calls ttsSegment from season-audio, uploads to lovio/<user_id>/entries/<entry_id>.mp3, patches the entry row. getEntryVoiceStatus({ entryId, userId }) is a cheap probe that returns { audio_url, is_stale, has_voice_clone, voice_label } without rendering — drives the button state on the frontend.
  • Routes (auth-required, owner-scoped):
    • GET /api/lovio/entries/:id/voice-status — probe
    • POST /api/lovio/entries/:id/voice-render — body { force?: boolean }
  • Frontend: <LovioHearButton> in /dashboard/entries/[id]/page.tsx (action-buttons row, next to the existing lovio flag toggle). State machine surfaces five distinct visuals: 🎙️ first-render, 🎧 cached + fresh, ↻ stale (entry edited or voice changed), ⏳ rendering, or → /dashboard/lovio/voice-setup link when no clone exists. On play, opens a fixed-bottom audio dock (portaled into the page) with <audio autoPlay> so the entry starts playing immediately.

"Playable in my stream" for note recordings (shipped):

  • Migration: 457_journal_entry_voice_stream.sql — adds journal_entries.voice_stream_enabled BOOLEAN NOT NULL DEFAULT FALSE + a partial index journal_entries_voice_stream_idx ON (user_id) WHERE voice_stream_enabled AND voice_audio_url IS NOT NULL. Once an entry has a cached voice render (migration 216), the author can flip it playable so it surfaces an inline player on their own /dashboard/stream.
  • Route (auth-required, owner-scoped): PATCH /api/journal/:id/voice-stream — body { enabled: boolean }. Enabling requires an existing voice_audio_url (400 No recording exists for this entry yet otherwise) and also forces the entry's content_visibility='public' (audio implies public text). Disabling only clears the flag; content_visibility is left as-is. Returns { voice_stream_enabled, content_visibility }. Lives in routes/journal.ts next to the PUT /:id update route.
  • getEntryVoiceStatus now also returns voice_stream_enabled so the entry-page toggle knows its initial state. The /api/journal list route surfaces voice_stream_enabled (metadata-level) + voice_audio_url (content-gated) via filterEntryForViewer in database/visibility.ts so the stream can render a player.
  • Frontend: entry-page toggle (🔊/🔇 icon in <LovioHearButton>) renders only once a recording exists; enabling shows a one-line "now public and playable in your stream" hint. Stream player: <StreamVoicePlayButton> in /dashboard/stream/page.tsx renders a small ▶/⏸ on qualifying cards and plays the CACHED MP3 inline (<audio preload="none">, no autoplay) — never re-synthesizes, so zero ElevenLabs cost on play.

JQ voice (A0) — JQ speaks in the user's own voice (shipped; the foundation for the Living Voice roadmap Track A):

  • Migration: 458_jq_voice.sql — (a) lovio_user_voices.consent_version INTEGER DEFAULT 1 for consent v2: the clone consent string (lovio-voice.ts LOVIO_CONSENT_STATEMENT + LOVIO_CONSENT_VERSION=2) now broadens scope from "lovio capsules only" to the user's own content incl. JQ goal-reminders; new clones stamp v2, pre-existing rows stay v1 and JQ refuses to speak in them until re-recorded (JQ_VOICE_MIN_CONSENT_VERSION). (b) jq_voice_clips — render-once cache keyed by (user_id, voice_id, text_hash) so identical text is never re-synthesized. (c) jq_dream_nudges — caches the composed nudge LINE per (user_id, dream_id, step_id) so the words stay stable (→ same cached audio). Both tables RLS-enabled with no policies (backend service-role only).
  • Service: services/jq-voice.ts. sayInUserVoice(userId, text, { featureKey }){ audio_url, cached }: consent-gates (throws NeedsConsentError/NoVoiceError), cache-lookup by text hash, else ttsSegment (quota-gated via user_id context) → uploadAudio('jq-voice/<userId>', '<hash>.mp3') → upsert cache. getDreamNudge(userId) composes one short sentence from the first active dream + getNextStep via complete({ model_key: 'claude-haiku-4-5' }), cached in jq_dream_nudges.
  • Routes (auth-required): routes/jq-voice.ts mounted at /api/jq/voicePOST /say { text } (≤600 chars) and GET /nudge?occasion= (204 when no active dream). Both return 409 NEEDS_CONSENT / NO_VOICE → frontend routes to /dashboard/lovio/voice-setup.
  • Cadence (migration 459): getDreamNudge(userId, occasion) supports morning | at_risk | step_complete | next_step, each with its own tone (OCCASION_DIRECTION) and cached separately (jq_dream_nudges.occasion, unique now (user, dream, step, occasion)). Frontend fires per-moment via shared helpers in lib/jqPresence.ts (fireSelfNudgeOrb / summonSelfNudge): <JqNudgeCard> picks morning (once/calendar-day, localStorage) on the first visit of a day else next_step (once/session); the dream detail page (dashboard/dreampro/[id]/page.tsx) fires step_complete right after handleCompleteStep. at_risk is wired via <StreakNudgeTrigger> (components/jq/StreakNudgeTrigger.tsx) on /dashboard: on load it checks GET /api/journal/streak and, when a streak is going but has_today is false (mirrors WritingStreakWidget's rule), fires once/day — and suppresses that session's ambient persona orb so the actionable nudge wins.
  • Coaching-aware nudge (DreamPro Glasses Voice Program, Phase 0) — migration 477_jq_coach_nudges.sql. When the caller is a DreamPro coaching client with a delivered (coach-approved) feedback decision, GET /api/jq/voice/nudge composes the line from that encouragement instead of the dream/step nudge — still spoken in the wearer's OWN voice (no new consent; the coach's words were approved via the shipped coach-brief review queue). getCoachingNudge(userId, occasion) in services/jq-voice.ts reads getLatestDeliveredEncouragement() (coach-brief.ts) → condenses the ≤120-word coach note into one short sentence (claude-haiku-4-5) → caches per (user, decision, occasion) in jq_coach_nudges so a given delivered decision always yields the same words → same jq_voice_clips audio row (no ElevenLabs re-spend). The route prefers the coaching nudge, falls back to getDreamNudge, and stamps source: 'coach' | 'dream' + feature_key jq.coach_nudge. The glasses app consumes the unchanged { text, dream_id, audio_url, ... } contract — zero glasses-app change. The composed-line normalizer sanitizeSpokenLine lives in the dependency-free services/jq-voice-text.ts (golden test tests/jq-voice-text.test.ts — cache-key stability). Coach's-voice / teammate's-voice are later phases (coach-clone-to-client deferred behind a new consent scope). Spec: docs/product/DREAMPRO_GLASSES_VOICE_PROGRAM.md.
  • Frontend: <JqNudgeCard> (components/jq/JqNudgeCard.tsx) at the top of /dashboard/dreampro — self-hides with no active dream, shows a calm setup prompt on 409, else the line + a ▶ play button. Playing dispatches the jq:speaking window event; the global JQ orb (Chatbot.tsx) listens and swaps its idle purple pulse for a faster amber pulseSpeaking while narrating.

Presence Orb (Track A″, P0)components/jq/PresenceOrb.tsx (+ .module.css), mounted globally in ClientOnlyComponents.tsx next to the JQ orb. A small avatar orb that rolls in from the right, collides with the JQ orb (reusing the jq:speaking amber pulse as an impact flash), and pops a speech bubble. Decoupled event bus: any surface fires window event presence:show with { subject, avatarUrl, message, audioUrl? }; one shows at a time, the rest queue. Entrance is silent — audio (the A0 nudge) only plays on tap, never autoplayed. <JqNudgeCard> summons a "self" orb once per browser session (sessionStorage 'jq-presence-shown') with the user's profile avatar; the inline card stays as the persistent replay surface. Stream-background fill: each orb ~50% of the time fills with the current time-of-day stream background (getCurrentStreamBackgroundUrl in lib/streamBackground.ts) as a "window into the person's world," with the avatar riding as a small corner badge (bgImageUrl on the presence:show detail).

  • P2 persona orbs (endpoint retained, UI removed 2026-07-12): GET /api/jq/voice/persona-presencegetPersonaPresence() picks a recent persona_scrapbook_entries row from an active persona and returns { persona_id, display_name, avatar_url, message, url } — the message is the persona's own "why this caught my eye" scrapbook note (no new generation, no cost; 204 when none). This ambient subject:'persona' orb was pure non-actionable flavor, so its dashboard trigger (<PersonaPresenceTrigger>) was removed — no persona orb rolls in anymore. The endpoint + getPersonaPresence() stay in place (unused, read-only, no cost) so the orb can be re-enabled by re-mounting a trigger if persona voice + richer choreography land later (see LIVING_VOICE_ROADMAP.md Track A″). The actionable subject:'self' orbs (JQ nudges, streak nudge) are unaffected.

"Hear about this" surface narrations (Living Voice A′.2a) — a short spoken page intro in the HOUSE voice (the founder's clone; HOUSE_VOICE_ID env, stock fallback). Render-once, serve-to-all: the first tap on a surface composes (claude-haiku-4-5, ~70 spoken words) + synthesizes + caches; every later tap returns the cached clip. Migration 462_surface_narrations.sql (surface_narrations, unique per surface_key, backend-only RLS).

  • Service services/narration.ts: SURFACE_REGISTRY (only registered keys render) — currently home, ethos, features, 30-day-reset, compare, about-jq, lovio, pricing, odessa, emberkiln. Cache key content_hash = sha256(seed + '\n' + voice_id), so re-recording the house voice OR editing a seed invalidates every surface. hearAboutSurface (lazy first-tap), rerenderSurface / rerenderAllSurfaces (force, current voice), listSurfaceStates (per-surface voice + rendered_at + stale/never flags).
  • Routes routes/narration.ts at /api/narration: public POST /hear { surface }; super-admin GET /admin/surfaces, POST /admin/rerender-all, POST /admin/rerender { surface }.
  • Frontend: <HearAboutThis surface=… className?=… /> (components/narration/HearAboutThis.tsx) — never autoplays, pulses the jq:speaking orb while playing; placed on /, /ethos, /features, /30-day-reset, /compare, /about/jq, /lovio, /pricing, /odessa, /emberkiln (per-page palette via className). Admin re-render console at /dashboard/admin/narration — lists every surface's rendered voice + staleness and offers "re-render all in the current house voice" (the button to push after re-recording) + per-surface re-render. Adding a surface = a SURFACE_REGISTRY seed + a <HearAboutThis> on the page (no migration).

AR/XR SDK release watcher (AR-glasses layer under the Living Voice) — a daily cron that watches for the hardware gate to open. Backs docs/product/AR_GLASSES_LIVING_VOICE.md, migration 463_sdk_release_alerts.sql.

  • Service services/sdk-release-watch.ts: TRACKED_SDKS (code, not data — Android XR Maven, Meta Spatial SDK npm + Wearables toolkit page, Snap Spectacles + Brilliant Labs GitHub releases). runSdkReleaseWatchTick resolves each source to one value (version / release tag / page_hash), compares to the newest sdk_release_alerts row per key: first sighting → baseline (no alert); changed → new row + a service_errors warning (so /errors catches it too). Per-source failures isolated + throttled. listSdkWatch / setSdkAlertStatus back the admin view.
  • Cron sdk_release_watch_tick (24h) wired in index.ts; registry key sdk_release_watch (cron-toggles.ts, maintenance, on by default, $0) + CRON_LABELS in system-health.ts.
  • Route routes/sdk-watch.ts at /api/sdk-watch (super-admin): GET / (tracked + latest + open alerts), POST /:id { status } (acknowledge/dismiss). Admin page /dashboard/admin/sdk-watch. Claude skill /sdk-releases (.claude/commands/sdk-releases.md). The trigger it's watching for: Android XR SDK leaving alpha (≈ consumer glasses ship) or the Meta Wearables toolkit appearing (≈ Ray-Ban opens a 3rd-party SDK). Adding a source = append to TRACKED_SDKS (no migration).

Downstream Glasses — the Mentra Live app (owned-hardware, first build) — the forward-voice loop on real glasses we control (product name "Downstream Glasses"; JQ remains the assistant voice on them). Plan: docs/product/OWNED_HARDWARE_HORIZON.md (Mentra Live chosen over Frame — Frame has no speaker). PRs #1175/#1176/#1178/#1181/#1185.

  • App: apps/glasses/ — a MentraOS AppServer (@mentra/sdk, TypeScript, deploys to Railway). Bootstrap src/index.tssrc/session.ts (attachJqSession, speakNudge). DreamPro speak-path: button/voice → GET /api/jq/voice/nudge (cached MP3) → session.audio.playAudio in-ear. Voice routing is Sophia-first with a keyword floor (src/intent.ts + src/sophia.ts, a license-clean first-party JSON-RPC client — the GPLv3 cicero-sdk is deliberately avoided; SOPHIA_ENABLED off by default, fails safe). Single-user auth = one HIVEJOURNAL_TOKEN (a durable device token, below). Tests: npm test (node --test via tsx), mock-session harness — no hardware.
  • Durable device token (migration 511 glasses_tokens): the long-lived HIVEJOURNAL_TOKEN for the glasses server, replacing the ~1h Supabase JWT that would 401 a persistent deploy within the hour. Format hjg_<hex>; authenticateUser prefix-routes it (middleware/auth.ts) → resolveGlassesToken (services/glasses-tokens.ts) resolves it to the wearer (no impersonation), so it works on every endpoint the glasses hit (JQ voice, Levers, soundtrack, AisleAsk). Owner-authed CRUD /api/glasses/tokens (mint returns raw value ONCE; sha256 hash stored; revoke instant, re-checked each request). Pure hashGlassesToken + isGlassesTokenFormat golden-tested (glasses-tokens-core.ts). Dashboard: "🔑 Connect this device" card on /dashboard/glasses. Apply migration 511.
  • Multi-user device linking (migration 513 glasses_device_links; plan GLASSES_MULTIUSER_AUTH.md): binds a MentraOS mentra_user_id to an HJ account so the glasses app can serve strangers (each acts as their OWN account) — the prerequisite for Mentra-store distribution. Owner mints a 6-digit code (/dashboard/glasses → "Link someone else's glasses", hashed + 15-min expiry), the wearer claims it from the device (POST /api/glasses/link {mentra_user, code}, app-secret authed), and per session the app resolves its user (POST /api/glasses/resolve {mentra_user}, app-secret authed) → a short-lived signed session token (hjs_…, HMAC via lib/glasses-session-token.ts, stateless, 24h, prefix-routed by authenticateUser) — no long-lived secret on the device. App-secret gate: middleware/glasses-app-auth.ts (GLASSES_APP_SECRET, constant-time; audit-recognized). Owner CRUD /api/glasses/links (mint/list/revoke). Pure normalizeLinkCode/hashLinkCode/formatLinkCode + the session-token round-trip golden-tested. Glasses runtime (built): onSession resolves the wearer when GLASSES_APP_SECRET is set; the per-session token rides an AsyncLocalStorage context (hivejournal.ts runWithSessionToken, wrapping the handler bodies in session.ts) so every client call uses THAT wearer's token with per-session isolation (no cross-wearer clobber) and single-user (unset secret) is byte-for-byte unchanged. Link command: parseLinkCommand ("link my glasses <digits|number-words>") → claimLinkCode → "restart JQ" (avoids mid-session auth swap). Untested on-device — needs a 2nd wearer to validate A≠B. Apply migration 513 + set GLASSES_APP_SECRET (same value on the glasses server).
  • Backend routes/glasses.ts at /api/glasses: GET /status (super-admin — { voice, hasActiveDream, lastSession }; voice/hasActiveDream via getJqVoiceReadiness() in services/jq-voice.ts, no LLM) + POST /session (the AppServer checks in each session → recordHeartbeat('glasses_session'), no interval) + POST /events / GET /events (traceability, below).
  • Admin status page /dashboard/admin/glasses (super-admin) — live readiness (voice / active dream / last check-in) + the phased roadmap; discoverable via the ⌘K command palette.
  • Traceability — a log of everything the glasses say (task 429a1306, Privacy & Trust): every spoken/played event — JQ nudges (with the MP3, replayable), lever-log receipts, Speech Mirror cue tones (label only — the matched utterance never leaves the device), soundtrack track-changes, system fallbacks — is reported best-effort at the moment it plays via reportSpoken() in apps/glasses/src/hivejournal.ts (fire-and-forget, never disrupts a session) → POST /api/glasses/events → migration 491_glasses_spoken_events.sql (glasses_spoken_events, kind CHECK, RLS backend-only). Wearer-facing log: /dashboard/glasses — kind-filtered list, newest first, audio replay. Privacy contract: log what the GLASSES said, never what the wearer said.
  • Speech Mirror (prep, COUNSEL-GATED, OFF by default) — a therapist-recommended self-awareness use: the wearer opts into phrases they want to catch themselves using; glasses reflect them back in the moment with a gentle, non-punitive cue. A mirror, not a therapist (not diagnosis/treatment/medical-device); on-device matching (utterance never leaves the device). Primitive src/watch.ts (checkUtterance + createWatcher debounce), wired behind WATCH_ENABLED. Doc: docs/product/JQ_SPEECH_MIRROR.md. Nothing patient-facing.
  • Posture (tech-neck coaching, OFF by default) — reads the glasses' frame-pitch stream: frame level = good, a held downward tilt (head dropped over a phone/desk) = slouch → ONE gentle spoken cue after a sustained hold. Calm by design: per-session calibrated baseline, hysteresis, 90 s cooldown, quiet hours, forgives brief glances. Split for testability: a pure createPostureMonitor state machine (fully unit-tested in src/posture.test.ts — calibration/sustain/cooldown/recovery/hysteresis, no SDK, no clock of its own) + a single SDK seam getHeadPitchStream that degrades to a no-op if the device exposes no head-position stream. On-device tuning without a redeploy: POSTURE_INVERT_PITCH (flip sign) + POSTURE_SIM_SEQUENCE (desk/CI drive of the whole loop). Wired behind POSTURE_ENABLED in src/session.ts; cues logged best-effort as kind:'posture' on /dashboard/glasses. Camera-based pitch fallback (no IMU needed) — WIRED end-to-endsrc/posture-vision.ts: recover the frame's pitch from a photo of a horizontal surface (floor/table/counter) via its edges' vanishing point (θ = atan((cy − v_horizon)/fy)); pure geometry, round-trip tested to 1e-9 (posture-vision.test.ts). Say "check my posture" → glasses photo → POST /api/glasses/posture/reference-edges (services/posture-vision.ts, gpt-4o-mini vision returns normalized edges) → tested geometry runs on-glasses (session.ts checkPostureFromCamera) → spoken tilt, logged kind:'posture' (source:'camera'). Gated on POSTURE_ENABLED + backend key; user-triggered only. Migration 595 widens glasses_spoken_events.kind CHECK to include cairn+posture (491 omitted both; reportSpoken is fire-and-forget so rejected inserts failed silently). Role: calibration + IMU-absent spot-check, not continuous; vision LLMs aren't pixel-exact so the angle is approximate. Ambient posture watch (opt-in auto-capture)attachPostureWatch (session.ts): jittered occasional photo → camera pitch → cue only on a sustained slouch; hard-gated (POSTURE_WATCH_ENABLED on top of POSTURE_ENABLED, daily cap, cue cooldown, quiet hours, jittered cadence); photo analyzed then discarded (angle only). Surfaced publicly on /features ("On your glasses") + a Posture panel on /dashboard/glasses (which also fixes EventKind/KIND_META missing cairn+posture). Second modality (specced, not built): BLE pressure-sensing smart insoles for stance/lean the glasses can't see. Doc: docs/product/POSTURE.md. Body-sensing → opt-in, non-punitive.

Lodestone / Cairn — geo-anchored memory stories (Phase 0 + 1a + 1b, BUILT)

The first slice of the Lodestone geo-anchored cache substrate: Cairn, the memory layer. A living person records their OWN audio story, drops a pin at a real place with a geofence, scopes it to family, and it plays for a family member who physically arrives inside the fence. No glasses, no NLU, no money, no cloned-voice-of-the-dead in Phase 0 — deliberately inside the existing Lovio consent envelope (own story, family-only). Platform plan: docs/product/LODESTONE_PLATFORM.md.

  • Migration 560_lodestone.sql — three tables, RLS on / no public policies (service-role only, like downstream_*): lodestone_anchors (place + geofence: lat/lng/radius_m default 75, access CHECK-locked to 'family', status), lodestone_payloads (kind CHECK-locked to 'memory_story', audio_url/transcript, FK → anchor ON DELETE CASCADE), lodestone_visits ((anchor_id, visitor_id) UNIQUE — first arrival is the moment, repeats dedupe). CHECKs + access/kind enums are the Phase-0 subset, widened in later phases. Apply migration 560 before the API works against prod (MCP ≠ prod).
  • Geofence core (deterministic, golden-tested): services/lodestone-geo.ts — pure haversineMeters + isWithinRadius (edge-inclusive). The server ALWAYS re-verifies proximity against the client-reported coords — a client "I'm here" is never trusted. Test: tests/lodestone-geo.test.ts (in/out/on-edge of a 75 m fence).
  • Service services/lodestone.ts: createAnchor, attachStory (owner-verified), uploadStoryAudio (reuses the Lovio/season-audio uploadAudio bucket — no new storage), listAnchorsForUser (owned + family-shared), nearbyForUser (family-visible anchors whose fence contains the caller), recordVisit (idempotent upsert). resolveAudioType accepts any ffmpeg-decodable audio (mp3/m4a/wav plus browser-recording webm/opus/ogg) and returns the SOURCE ext — the route transcodes any non-mp3 before store. Family access scope = familyUserIds(), which reuses resolveFamilyOwnerId (family-access.ts, migration 075) — whole-group in Phase 0 (per-recipient scoping deferred).
  • Audio transcode (Phase 1a): services/lodestone-audio.tstranscodeToMp3(buffer, srcExt) shells out to the bundled render-kit ffmpeg (-vn -c:a libmp3lame -b:a 128k -ar 44100, 60s timeout, temp-dir try/finally cleanup — no new dep, local CPU, no external service). Browser recordings are WebM/Opus (Chrome) / MP4/AAC (Safari) and iOS Safari can't decode WebM/Opus, so the route re-encodes any non-MP3 upload to a real MP3 (audio/mpeg) before uploadStoryAudio — MP3 skips transcode (fast path). Golden test: tests/lodestone-audio.test.ts (WebM/Opus fixture → ffprobe reports mp3, duration > 0).
  • Routes routes/lodestone.ts, mounted at /api/lodestone (next to /api/lovio in index.ts), all authenticateUser, catches → recordServiceError: POST /anchors (create + optionally attach a story — multipart audio clip via multer, 25 MB cap; any browser recording / voice memo, non-MP3 transcoded to MP3 server-side; or an audio_url/transcript in the body), GET /anchors (mine + family), GET /nearby?lat=&lng= (server-verified proximity), POST /anchors/:id/visit.
  • Front-end apps/frontend/src/app/cairn/ — route /cairn; server page.tsx (Navbar + metadata) + 'use client' CairnClient.tsx. Warm Emberkiln palette (ember #df8f33/#eaa652, burnt #a1581a) on dark. Three tabs: leave a story (in-app MediaRecorder recording — re-enabled in Phase 1a now the server transcodes — OR voice-memo upload + navigator.geolocation with a manual lat/lng fallback + place label + radius), what I've left (GET /anchors), near me (GET /nearby → any story inside a fence plays; play fires POST /anchors/:id/visit). Uses apiRequest (FormData-aware).
  • Phase 1b — glasses arrival playback (the homeland moment), BUILT: the same Cairn unlock, arrival-triggered, through the Mentra Live glasses. Location source resolved — the @mentra/sdk exposes device GPS via session.location.subscribeToStream({ accuracy: 'tenMeters' }, …) (returns an unsubscribe fn), fine for the 75 m fence; no phone-driven fallback needed.
    • Backend wrapper: GET /api/glasses/lodestone/nearby?lat=&lng= in routes/glasses.ts → the single NEAREST in-range playable story + a ready-to-speak intro line + place_label/transcript. Reuses nearbyForUser (family scope + server-verified geofence) via nearestCairnStory (services/lodestone.ts), which sorts by haversineMeters and records the arrival visit server-side (one memory at a time — the glasses never play several at once). Authenticated as the wearer (same durable glasses token as every /api/glasses route). A 'cairn' kind was added to the glasses_spoken_events log (migration 491).
    • Glasses app apps/glasses/src/cairn.ts: location provider seam getLocationStream(session, onFix) wraps the SDK stream but a CAIRN_SIM_LAT/CAIRN_SIM_LNG env emits one fixed fix instead — so the whole poll → speak → play loop is desk/CI-testable without hardware. playCairnOnArrival resolves the nearest memory, dedupes by anchor id (marked before the awaits so a re-entrant fix can't double-fire), session.audio.speak(intro) then playAudio(audio_url), and logs a 'cairn' event. attachCairn wires it onto session.location (in-flight guard, unsubscribe returned); called from attachJqSession (session.ts) behind cairnEnabled() (opt-in: CAIRN_ENABLED=1 or a sim coord) + a resolvable-wearer guard, unsubscribed on onDisconnected. Event-driven off the location stream — no polling cadence, so no cron/heartbeat (the per-session glasses_session check-in already heartbeats). Client fetch fetchNearbyCairn + CairnStory in hivejournal.ts.
    • Sim-validated, device deferred: the arrive→speak→play loop is proven at a desk by apps/glasses/src/cairn.test.ts (mock session + simulated location + mocked backend: in-fence play, dedupe, out-of-range silence, error-swallow, SDK-fix path, sim seam). The real arrival experience needs the Mentra Live in hand + a real/spoofed GPS fix — human-only.
  • Phase 0 e2e (record → arrive → hear) is deferred to a human — needs a dev Supabase with migration 560 applied + a phone in a real geofence. Later phases (NLU gatekeeper, SIGNAL match layer, location attestation, economy) are speced in the platform doc, not built.

Captures rail — app-neutral glasses/phone capture persistence (v1 owner-scoped + v2 partner-grant read, BUILT)

The neutral capture-persistence primitive: a wearer (glasses) or phone captures a photo + optional spoken note + location → a durable, owned, signed-URL asset, tagged by a purpose so multiple consumers reuse ONE endpoint instead of forking a rail each. Consumers by purpose: secondset_field (SecondSet / QuickSites field-capture trust layer), aisleask_planogram (AisleAsk store-map building), hj_lifelog (HiveJournal life-capture), cairn (Lodestone/Cairn). There was no capture-persistence rail before this — AisleAsk's photo was transient (base64 → OpenAI → discarded). Contract (source of truth, cross-repo): crosstalk/contracts/glasses-capture.md.

  • App-neutral by design: the ONLY consumer-specific data rides the opaque context jsonb (SecondSet → {work_order_id,…}, AisleAsk → {store_id,…}, HJ → {entry_id}, Cairn → {anchor_id}) — the rail stores + returns it verbatim. Add a consumer = widen the purpose CHECK, no other schema change. No per-consumer columns.
  • Migration 561_captures.sql — one captures table, RLS on / no public policies (service-role only, like downstream_* / lodestone_*): purpose CHECK-locked to the four tags, wearer_user_id (→ auth.users, always token-derived), storage_path/thumb_path/mime/width/height (nullable — note-only captures have none), note, lat/lng/accuracy_m, captured_at, context jsonb, consent jsonb, status pending|delivered + delivered_at. Indexes on (purpose, created_at desc) + (wearer_user_id, created_at desc). Human-provisioned (MCP ≠ prod): apply migration 561 to prod AND create a PRIVATE Supabase storage bucket named captures — neither is automatic.
  • Service services/captures.ts: createCapture (validates via the pure validateCaptureInput, then mirrors journal-entry-attachments.ts createAttachment — uploads the original to the private captures bucket at <userId>/<captureId>.<ext>, generates a 400px webp thumbnail via sharp, ~1h signed URLs; does NOT call createAttachment directly because that's entry-scoped), signCapture (~1h createSignedUrl for both paths), listCapturesForUser (owner-scoped, purpose/since/status/limit filters, newest first, signed), ackCapture (idempotent → delivered). v2 partner read (owner+purpose scoped): listCapturesForOwnerPurpose(ownerUserId, purpose, {since,status,limit}) + ackCaptureForOwnerPurpose(ownerUserId, purpose, id) — both fail closed (wearer_user_id === grant.owner_user_id AND purpose === grant.purpose), so a partner can never read/ack outside its granted owner+purpose. Owner-scoped functions untouched.
  • Consent is load-bearing (enforced in validateCaptureInput, recorded in the consent jsonb): a consent.subject === 'customer' capture (SecondSet — recording in someone's vehicle/space) REQUIRES consent.obtained === true or the rail rejects it 400; self/none (HJ life-capture, Cairn — the wearer is the subject) pass freely. This is a deliberate, documented departure from the glasses' "never log what the wearer said" default — a spoken note is now stored, only under a set purpose + consent.
  • Routes routes/captures.ts, mounted at /api/captures (next to /api/lodestone in index.ts), catches → recordServiceError (service 'captures'): POST / (owner-only authenticateUser; multer memoryStorage, 10 MB / 1 file, field image — optional; fields purpose/note/lat/lng/accuracy_m/captured_at + context/consent as JSON strings; wearer_user_id derived from the token, NEVER the body), GET /?purpose=&since=&status=&limit= and POST /:id/ack (dual-auth via ownerOrCapturePartner, see v2 below).
  • v2 partner-grant read — BUILT. A shop owner grants a partner (QuickSites, for SecondSet) scoped read of their captures of a purpose; the partner pulls + acks with a partner key + grant token, scoped to that owner+purpose only. Reuses the generic partner-key auth from About-That verbatim (verifyPartnerKey/ALLOWED_PARTNERS/PARTNER_QUICKSITES/hashGrantToken imported from services/about-that-provisioning.ts + -core — NOT forked; About-That's grants/tables untouched). Grant scope is captures-specific (partner, owner_user_id, purpose).
    • Migration 562_capture_partner_grants.sql — table capture_partner_grants (sibling of about_that_partner_grants), RLS on / no public policies: partner, owner_user_idauth.users, purpose (same CHECK set as captures), token_hash UNIQUE (sha256 of the raw token — raw shown to owner once, mirrors About-That), label, created_at/revoked_at/last_used_at, index (owner_user_id, created_at desc). Human-provisioned — apply to prod yourself.
    • Service services/captures-provisioning.ts: mintCaptureGrant(ownerUserId, {partner?, purpose, label?}){grant, token} (raw token ${partner}_${base64url(32 bytes)}, only its hash stored — mirrors About-That's mintGrant); resolveCaptureGrant(partnerId, partnerKey, grantToken){grantId, owner_user_id, purpose} (fail-closed: bad/absent key, unknown/revoked token, partner mismatch → 401); touchCaptureGrant, listCaptureGrants, revokeCaptureGrant (owner-scoped, idempotent).
    • Owner grant management (JWT-only, authenticateUser): POST /api/captures/grants {partner?, purpose, label?}201 {grant, token}, GET /api/captures/grants{grants}, DELETE /api/captures/grants/:id{ok}.
    • Partner read pathmiddleware/captures-partner.ts ownerOrCapturePartner (mirrors about-that-partner.ts ownerOrPartner): when X-Partner-Id is present it takes the partner path (resolve grant → scope to grant.owner_user_id + grant.purposetouchCaptureGrant); else falls through to authenticateUser. Header contract: X-Partner-Id (partner slug quicksites, presence selects the path), X-Partner-Key (shared secret, env PARTNER_QUICKSITES_SECRET), X-Partner-Grant (owner-minted raw grant token). On the partner path the query purpose is ignored — the grant's purpose is authoritative. QS matches a pulled capture to a job by context.job_id, then POST /:id/ack.
    • Golden test tests/captures-provisioning.test.ts — mint→resolve round-trip, fail-closed (bad/absent key, revoked token, unknown partner, wrong-owner revoke no-op), and scoping (resolve output is exactly the granted owner+purpose; partner ack refuses a capture of another owner or another purpose). Deterministic partner-key verify + hash exercised for real; supabase (stateful in-memory) + sharp mocked. 12 tests.
  • Golden test tests/captures.test.ts — locks the deterministic core: purpose allow-list, the consent gate (customer-without-obtained throws; self/none pass), empty-capture rejection, coordinate validation. supabase + sharp mocked via jest.unstable_mockModule so it runs without a bucket. 21 tests.
  • AisleAsk scan → emit an aisleask_planogram capture is a noted follow-up, not wired here.
  • Full e2e (real upload + partner pull) is deferred to a human — needs migrations 561 + 562 applied, the captures bucket, PARTNER_QUICKSITES_SECRET set, and a dev Supabase. (QS's SecondSet ingest is built to rework into this partner puller the moment the grant lands — see the contract's "Still to build → item 1".)

SecondSet — glasses job binding + owner→tech async voice notes (BUILT, INERT by default)

The shop-owner↔tech beat on top of the captures rail: a tech (glasses wearer) binds their session to a QuickSites-issued per-job capture_token, then their captures carry {capture_token, job_id, shop_id} in context; the shop owner leaves async voice notes the glasses poll + play in-ear. Closes the contract's "Still to build" items 2 (owner→tech voice note) and 3 (job-binding glasses UX). Contract: crosstalk/contracts/glasses-capture.md.

  • Partner binding-by-job read (lazy tech-identity discovery)GET /api/glasses/binding is dual-auth: no partner header → the tech's own activeBinding (unchanged); X-Partner-Id present → partner path (verifyPartnerKey + ALLOWED_PARTNERS, fail-closed 401, ?job_id= required) → bindingForJob(partner, job_id) returns { tech_ref, job_id, bound_at }tech_ref = the wearer's HJ user_id (the same id QS passes as target_user_id to /voice-notes), scoped to the partner's own bindings, no other fields exposed. Lets QuickSites learn a tech's ref on first bind (→ their service_shop_techs roster) with no onboarding UX. No migration (reuses glasses_job_bindings).
  • Migration 563_glasses_secondset.sqlglasses_job_bindings (partial-unique (wearer_user_id) WHERE active → exactly one active binding per wearer; capture_token, job_id==work_order_id, shop_id, partner default quicksites) + glasses_voice_notes (target_user_id→auth.users, audio_url, text, status pending|played, source_partner, job_id; index (target_user_id, status, created_at)). RLS on, no public policies. Human-provisioned — apply to prod yourself.
  • Service services/glasses-secondset.ts: parseCaptureTokenPayload (PURE — validates the QR/companion payload), bindJob/activeBinding/releaseBinding (a new bind releases the prior active), resolveVoiceNoteTarget (by target_user_id, or by job_id via the active binding under the same partner), bindingAuthorizesNote (PURE consent decision) + enqueueVoiceNote (the load-bearing consent gate — a note enqueues ONLY when the target has an ACTIVE binding whose partner (+ job/shop when scoped) matches; else VoiceNoteConsentError 403 → no unsolicited audio), pendingVoiceNotes/markVoiceNotePlayed. A text-only note is TTS'd through the house voice via studio-narrate.ts narrateLine (cached by content hash — no ElevenLabs re-spend). QR decode lives in services/glasses-qr.ts (sharp → raw RGBA → jsqr; new backend dep jsqr) — kept separate so the service test needn't boot sharp/jsqr.
  • Routes (in routes/glasses.ts):
    • Binding — the TECH authenticates (authenticateUser; picking a job IS their opt-in/consent): POST /api/glasses/bind {capture_token, job_id, shop_id} (companion-pick + post-QR-decode), POST /api/glasses/bind/scan {image_base64} (server-side QR decode; a non-decodable/non-JSON QR → clear 400), GET /api/glasses/binding{binding|null}, POST /api/glasses/unbind.
    • Voice notes — PARTNER path (ownerOrCapturePartner, so QS sends with X-Partner-*): POST /api/glasses/voice-notes { target_user_id? | job_id, text? | audio_url? } → resolve target → enqueue (binding-exists = consent gate; source_partner from X-Partner-Id); GET /api/glasses/voice-notes/pending (tech-authed), POST /api/glasses/voice-notes/:id/ack (tech-authed).
  • Glasses (apps/glasses/src/secondset.ts, INERT unless SECONDSET_ENABLED): bindJobFromScan ("scan job" voice intent via intent.ts secondset_bindrequestPhotoPOST /bind/scan), activeBindingContext (cached 60s — the capture path in capture.ts tags each shot with the binding context), attachVoiceNotePoll (session-scoped setInterval draining pending notes → playAudio → ack, deduped by id; wired in session.ts, torn down on disconnect). This poll is a client-side loop in the glasses AppServer, not a backend cron — the recordHeartbeat/CRON_REGISTRY machinery is backend-only (supabase service role) and unavailable in apps/glasses; same shape as the Cairn location stream, so no heartbeat applies.
  • Consent gate is load-bearing: a voice note requires the target tech to have an active binding (their opt-in via job-pick/scan) whose partner matches the sender. No binding / wrong partner / wrong job → 403, nothing enqueued.
  • Tests: backend tests/glasses-secondset.test.ts — 15 golden cases (payload parse, one-active-binding, target resolution, the consent gate, TTS-vs-audio_url, pending/ack; supabase + studio-narrate mocked). Glasses secondset.test.ts — 8 node:test cases (enable gate, bind-command routing, bind/scan post, cached binding context, voice-note poll + dedupe).
  • Out of scope (cross-app, with QS): the companion pick-SURFACE, the tech↔HJ identity mapping, and the QS-side capture_token mint. HJ builds the POST /bind endpoint the companion calls, not the companion UI.

Lovio flag UI surfaced everywhere (shipped):

  • The journal_entries.lovio_flagged_at column + PUT /api/lovio/entries/:id/flag + GET /api/lovio/flagged-entries route all already existed from v1 schema — this ship wires the UI.
  • Entry detail page (apps/frontend/src/app/dashboard/entries/[id]/page.tsx) — small <LovioFlagButton> component sits in the action-buttons row next to Share/AI/Delete. Optimistic local toggle (flips immediately, reverts on API failure). Visually distinguished by background color + tint (rose-tinted bg when flagged, neutral when not).
  • Capsule picker drawer (apps/frontend/src/app/dashboard/lovio/[id]/page.tsx) — openPicker now fetches /api/journal?limit=30 AND /api/lovio/flagged-entries in parallel, dedupes by entry id, lists flagged entries first (newest-flag-first) with a 💌 badge, then recent entries below.
  • Lovio pile strip on /dashboard/lovio — collapsed strip showing the count ("💌 N entries in your lovio pile") that expands to a 6-entry preview with one-tap links to each entry's detail page. Self-hides when the pile is empty. Listens for the lovio-flag-toggled window event so flagging from anywhere refreshes the count without a reload.
  • Inline 💌 on dashboard entry cards — the lovio-flag toggle also lives next to the inline 📌 pin button on /dashboard journal cards (handleToggleLovioFlag in apps/frontend/src/app/dashboard/page.tsx). Both buttons dispatch their own *-toggled window events; widgets that depend on the corresponding pile listen and re-fetch. Same loose-coupling pattern any future cross-surface refresh should reuse.

Preview-as-recipient on the capsule detail page (shipped):

  • Sealed capsules: existing delivery_token lets the author open /lovio/unlock/<token> in a new tab — byte-for-byte the same URL the recipient gets emailed.
  • Draft capsules: backend route GET /api/lovio/capsules/:id/preview (auth + owner-only via .eq('user_id', ownerUserId) inside getCapsuleByOwnerForPreview in lovio-delivery.ts) returns the same RecipientCapsuleView shape. Frontend page /dashboard/lovio/[id]/preview renders the same UI the public unlock page renders. Differs deliberately: auth-gated (fetches client-side via apiRequest), works without a delivery_token, and never POSTs to /opened — author opens don't corrupt the recipient's first-open timestamp. Sticky amber "PREVIEW" banner makes the author-context unambiguous.

Owner controls + owner-fallback delivery (shipped):

  • Delivery cron now handles the no-recipient-email case: when recipient_email IS NULL at delivery time, it sends the link to the OWNER (profiles.email) with a "forward this manually" template instead of silently leaving the capsule undeliverable. Tick result counters renamed: sent_to_owner_for_forward + undeliverable (no email anywhere). delivery_email_sent_at is stamped in both cases so the cron is idempotent.
  • Backend service: manualResendDelivery({ capsuleId, userId, mode }) in lovio-delivery.ts. Modes: 'recipient' (force-send to recipient_email), 'self' (preview to owner's email — uses the real recipient template so they see exactly what the recipient will), 'auto' (recipient if set, else owner with forward-template). Only stamps delivery_email_sent_at when actually sending to the recipient — the cron's stamp stays semantically meaningful.
  • Route: POST /api/lovio/capsules/:id/resend-delivery (auth, owner-scoped).
  • Frontend: /dashboard/lovio/[id] sealed-branch now includes a Delivery panel with status line ("Queued / Sent on X / Opened on Y by recipient"), the /lovio/unlock/<token> URL with copy-to-clipboard + "Open as recipient" link, and two action buttons: "Send now to recipient" (disabled when no recipient_email) and "Send a preview to me." Recipient-email-missing state surfaces an amber warning so the owner knows what will happen at delivery.

Delivery cron + recipient page (shipped):

  • Service: services/lovio-delivery.ts exports runLovioDeliveryTick() + getCapsuleByDeliveryToken(token) + markCapsuleOpened(token).
    • runLovioDeliveryTick() finds sealed + not yet unlocked + not revoked + date-trigger capsules where unlock_at <= now() AND delivery_email_sent_at IS NULL. Hydrates sender display names from profiles. Sends one styled email per capsule to recipient_email via Resend with a CTA to ${SITE_URL}/lovio/unlock/<delivery_token>. Stamps delivery_email_sent_at on success. Returns counters for pending / sent / no_recipient_email / errors. Capsules without a recipient_email are counted separately (owner needs to forward manually) and left for the next tick.
    • getCapsuleByDeliveryToken(token) returns the recipient view: capsule + sender name + ordered entries from lovio_capsule_entries (materialized at seal). Token validation is a length check; the lookup itself is the actual gate.
    • markCapsuleOpened(token) atomic update with WHERE unlocked_at IS NULL so it's idempotent — unlocked_at is the first-open timestamp, not most-recent.
  • Routes (mounted at /api/lovio, public — no auth):
    • GET /unlock/:token — returns the recipient view; 404 if unknown; 410 Gone if revoked
    • POST /unlock/:token/opened — idempotent stamp of unlocked_at
  • Cron: hourly tick in index.ts, 250s boot delay, heartbeat-tracked as lovio_delivery_tick. CRON_LABELS updated.
  • Frontend: /lovio/unlock/[token]/page.tsx is a server component that fetches the capsule from /api/lovio/unlock/:token (cache: 'no-store'). Renders three states: happy (sender greeting + intro + audio + entries as prose), 410 (revoked), 404 (unknown). UnlockClient fires a non-blocking POST to /opened on mount so unlocked_at is stamped without making the recipient wait. Page is designed to feel like opening a letter — centered column, generous whitespace, no nav chrome. Bookmarkable: the token-based link works forever (until revoked).

Quarterly reminder cron (shipped):

  • Migration: 215_lovio_capsule_reminders.sql — adds lovio_capsule_reminders log table + profiles.lovio_reminders_opted_out_at opt-out column.
  • Opt-out toggle (settings UI shipped): LovioPreferencesSection on /dashboard/settings renders a single toggle backed by GET /api/lovio/preferences + PUT /api/lovio/preferences (in routes/lovio.ts). PUT writes a timestamp to profiles.lovio_reminders_opted_out_at (or nulls it). Optimistic toggle (flips instantly, reverts on API failure). Recipient delivery emails are unaffected — opt-out only governs the founder-side reminder cadence.
  • Service: services/lovio-reminders.ts runLovioReminderTick() — finds sealed, not-yet-unlocked, not-revoked, date-trigger capsules where unlock_at is >30 days out; groups by user; joins profiles for email + opt-out check; looks up lovio_capsule_reminders rows in the trailing 90 days (the per-user "do we need to remind" gate); for each eligible user, sends ONE styled Resend email listing all of their pending capsules + a CTA back to /dashboard/lovio; inserts an audit row. Skips opted-out users + users with a reminder sent in <90 days. Failures still insert a row with send_error populated.
  • Cron: 24h tick wired in index.ts, heartbeat-tracked as lovio_reminder_tick. The per-user 90d cap is enforced INSIDE the tick, not by the schedule, so a deploy that bounces the server doesn't double-fire.
  • Email design: rose-tinted card, intro greeting using first name from profiles.name, bullet list of capsules ("'<title>' — for <recipient>, opens <date> (<relative>)"), CTA button to /dashboard/lovio, footer mentioning opt-out path.
  • TODO: wire a frontend opt-out toggle on /dashboard/settings (the column exists; just needs UI).

Go-live hardening — long-capsule seal + cron kill switches (shipped):

  • Seal service: services/lovio-seal.ts sealCapsule() builds the narration script, atomically claims sealed_at (compare-and-swap so a double-click can't double-charge ElevenLabs), renders in the owner's cloned voice, uploads to lovio/<user_id>/capsules/<id>.mp3, mints delivery_token; rolls sealed_at back on any failure so a failed seal is retryable.
  • Multi-chunk TTS stitching (removed the old 4,500-char hard cap): chunkNarration(script, maxChars) splits a long script at natural seams — paragraph breaks (\n\n) first, then sentence boundaries, then a hard slice for a run-on with no punctuation — so each piece is ≤ the per-request cap. Each chunk renders via ttsSegment (tagged feature_key: 'lovio_seal' for spend attribution; no user_id so seals aren't quota-gated) and the MP3s are stitched with concatMp3Buffers (ffmpeg stream-copy concat — clean because every chunk is the same voice/model/settings; a single-chunk capsule passes through untouched). Overall ceiling is LOVIO_MAX_NARRATION_CHARS (40k, a spend/latency guard) — over that, seal errors asking the owner to split into more than one capsule. Golden test: tests/lovio-seal-chunk.test.ts locks the chunker (never over cap, lossless, no empty chunks).
  • Crosstalk live viewer (migration 499 crosstalk_messages): super-admin page /dashboard/admin/crosstalk rendering the HJ↔QuickSites Claude-session collaboration thread (the _SilverLamp/crosstalk/ file mailbox) in near-realtime (polls GET /api/crosstalk/messages, superadmin-gated, routes/crosstalk.ts). The deployed app can't read the local files, so the HJ session mirrors them into the table via scripts/crosstalk-sync.ts (idempotent upsert by filename; acked=in archive/), auto-fired best-effort by crosstalk/bin/crosstalk after send/ack. HJ is the single writer (only it holds prod service-role creds); no cron (Railway has no access to the local files) — it's session-driven, hence the "last synced" stamp. parseCrosstalkFile is the pure frontmatter parser.
  • Cron kill switches: the three lovio crons (lovio_delivery_tick, lovio_reminder_tick, lovio_waitlist_drip_tick) are now registered in CRON_REGISTRY (services/cron-toggles.ts, defaultEnabled: true to preserve current behavior) with a top-of-tick if (!(await isCronEnabled(...))) return guard in index.ts. Super-admin can now pause any of them in ~60s at /dashboard/admin/crons — critically the delivery cron, which emails REAL recipients — without DISABLE_CRONS (which kills every cron) + a Railway redeploy. All three remain no-ops without RESEND_API_KEY.
  • Readiness preflight: GET /api/lovio/admin/readiness (super-admin, in routes/lovio.ts) reports the running server's go-live wiring as booleans only (never the secret values): elevenlabs_configured (seal render), resend_configured (delivery email), crons_globally_enabled (DISABLE_CRONS !== 'true'), and each lovio cron's toggle state. Returns { ready, checks, blockers }ready ANDs the hard requirements (render + deliver + delivery cron firing). Lets the founder confirm prod is wired without shelling into Railway.

Notebook-bound capsules (shipped):

  • Migration: 214_lovio_capsule_notebooks.sql adds lovio_capsules.source_notebook_id UUID REFERENCES notebooks(id) ON DELETE SET NULL + a partial index for non-null values. SET NULL keeps the capsule alive (in manual mode with whatever entries exist) if the notebook is deleted.
  • A capsule is in one of two modes at any time:
    • Manual (source_notebook_id IS NULL): entries managed via lovio_capsule_entries — owner hand-picks them via the entry-picker drawer
    • Notebook-bound (source_notebook_id set): the bound notebook IS the entry list. listCapsuleEntries reads journal_entries WHERE notebook_id = source_notebook_id live until sealed; new entries written into the notebook auto-join the capsule
  • addEntryToCapsule + removeEntryFromCapsule reject mutations on notebook-bound capsules ("write the entry into the notebook instead").
  • sealCapsule (in lovio-seal.ts) materializes the notebook entries into lovio_capsule_entries at seal time so post-seal notebook edits + deletions can't drift the audio's backing entry list. The rendered MP3 is also a snapshot, so the audio is doubly stable.
  • Frontend: new-capsule form on /dashboard/lovio has a two-button source-mode picker ("📝 Pick entries by hand" / "📓 Dedicate a whole notebook"). When notebook mode is picked, a select populates from GET /api/notebooks. Notebook-bound capsules show a 📓 badge on list cards and a clear binding strip on the detail page with a one-tap link back to the notebook. Entry picker is hidden when bound; a "+ Write a new entry" link drops the user into the new-entry composer pre-scoped to the bound notebook instead.

Capsule sealing + audio render (shipped):

  • Service: apps/backend/src/services/lovio-seal.ts. sealCapsule({ capsuleId, userId }) validates the capsule (exists / owned / not sealed / not revoked / date-trigger only / unlock_at >= now+1h / at least one entry / active voice clone), builds a narration script from the intro message + each entry's title + content with ... separators between entries, enforces a 4500-char ElevenLabs safe limit, calls ttsSegment(script, voice_id, 'default') reusing the same TTS helper as season audio, uploads the resulting MP3 to lovio/<user_id>/capsules/<capsule_id>.mp3, mints a crypto.randomBytes(24).toString('base64url') delivery token, and patches the capsule row with sealed_at, voice_id, voice_label, audio_url, audio_rendered_at, delivery_token. revokeCapsule is idempotent and rejects already-unlocked capsules. Multi-chunk stitching for longer capsules is a future iteration.
  • Routes: POST /api/lovio/capsules/:id/seal, POST /api/lovio/capsules/:id/revoke (both auth, owner-scoped via the service helpers).
  • Frontend: /dashboard/lovio/[id] detail page now exposes a "Seal capsule" button that's disabled until the user has both at least one entry AND an active voice clone (probed on mount via GET /api/lovio/voice). Sealed capsules render an inline <audio controls> of the snapshot MP3 with copy ("This is exactly what {recipient} will hear when the capsule opens"). Revoke button appears for sealed-but-not-unlocked capsules. Voice-missing state surfaces a "Record your voice →" CTA linking to the setup page.

Still to ship in v5: delivery cron (hourly heartbeat-tracked job that finds pending capsules with unlock_at <= now() and emails recipients via Resend with the /lovio/unlock/<delivery_token> link), /lovio/unlock/[token] recipient page (loads capsule by token + renders the snapshot audio + intro + readable entries; sets unlocked_at on first open), payment integration (per-capsule seal charge or subscription tier). (MediaRecorder consent flow + POST /voice/setup + POST /v1/voices/add upstream call), capsule sealer (render audio via existing TTS pipeline → stitch → upload → mint delivery_token → set sealed_at), delivery cron (hourly heartbeat-tracked job that emails recipients via Resend), /lovio/unlock/[token] recipient page, payment integration (per-capsule seal or subscription tier).

Notebook-Level Mood Snapshot

The dashboard's mood-snapshot widget now drills into a single notebook. GET /api/journal/mood-stats accepts an optional notebook_id query param (treats the string 'null'/'none' as orphan-only, same convention as the entries list endpoint); the MoodSnapshotWidget component picked up notebookId + days props that get URL-encoded into the request. On /dashboard/notebooks/[id] we mount the widget with days=365 so long-running notebooks still get a meaningful rollup — self-hides at total_tagged_entries < 3 so empty/private notebooks stay quiet.

Month Calendar View (/dashboard/calendar)

Full month-grid view of journal entries with each day cell colored by the dominant mood for that day + a count badge on multi-entry days. Day click loads the entries inline. Prev/next month navigation; "Jump to today" link surfaces when off-month. Linked from the writing-streak widget's 30-day heatmap as "Open calendar →".

  • Backend: GET /api/journal/calendar?year=YYYY&month=MM in apps/backend/src/routes/journal.ts. Returns { year, month, days: [{ day, count, dominant_mood }] } where dominant_mood is the most-frequent mood across that day's entries (null when none tagged). Defaults to current UTC month; year clamped to [2020, 2099]. Single DB query — select mood, created_at over the month window, then in-memory bucket by getUTCDate().
  • Frontend: apps/frontend/src/app/dashboard/calendar/page.tsx. Monday-first 7-col grid (leading-blanks padding via (getUTCDay() + 6) % 7). Day cells: MOOD_BG map (same emoji + color tier as MoodSnapshotWidget) for tinting, ring-2 rose for selected, ring-1 gray for today. Selected day fetches /api/journal?since_days=…&limit=100 and filters client-side to that day's range (cheap — server-side per-day filter not yet needed).
  • Day-entry list links straight to /dashboard/entries/[id] so a click→read flow stays one tap.

Sunday Weekly Recap Email

A second opt-in email cron alongside the daily prompt nudge — different vibe. Daily nudge says "go write today"; weekly recap shows "look at what you wrote." Reflective + content-rich.

  • Migration: 226_weekly_recap_email.sql — adds profiles.weekly_recap_email_opted_in_at + weekly_recap_email_last_sent_at plus a partial index over (last_sent_at NULLS FIRST) WHERE opted_in_at IS NOT NULL.
  • Service: services/weekly-recap-email.ts runWeeklyRecapEmailTick(). Gates: Sunday only (getUTCDay() === 0), inside the 09-23 UTC waking window, per-user 6-day cap via last_sent_at. Pulls the user's trailing 7 days of entries, computes entry/word/day counts + dominant mood + top 3 mentions, fetches one featured highlight (most-recent from this week if any, else a random older one as a "remember this" callback). Empty-weeks land a single send (the column gets stamped regardless) so subsequent missed weeks stay quiet. Capped at 100 users per tick.
  • Cron: hourly tick in index.ts, 290s boot delay, heartbeat-tracked as weekly_recap_email_tick. CRON_LABELS updated.
  • Routes (auth): GET /api/user/weekly-recap-preferences returns {opted_in, opted_in_at}; PUT accepts {opted_in: boolean} and stamps the timestamp or nulls it.
  • Settings UI: WeeklyRecapPreferencesSection on /dashboard/settings — optimistic toggle, default off, sits in its own "Weekly recap" card below the daily nudges card.

Daily Writing-Prompt Email Nudge

Opt-in retention cron that sends one email per day to subscribed users with today's writing prompt + a one-tap link to the entry composer.

  • Migration: 221_daily_prompt_email.sql — adds profiles.daily_prompt_email_opted_in_at + daily_prompt_email_last_sent_at plus a partial index over (daily_prompt_email_last_sent_at NULLS FIRST) WHERE daily_prompt_email_opted_in_at IS NOT NULL so the cron's "who needs sending today" lookup scans only the opted-in subset.
  • Service: services/daily-prompt-email.ts runDailyPromptEmailTick(). Gates: (1) the tick is a no-op outside the 09-23 UTC waking-hours window so users don't get emails at 3am; (2) finds candidates whose last_sent_at is null or before today's UTC day boundary; (3) batch-queries journal_entries to skip users who already wrote today (nudge is for absent users, not heavy writers); (4) sends via Resend, stamps last_sent_at on every candidate (sent OR skipped) so the cron doesn't keep re-checking them today. Capped at 100 candidates per tick. Prompt is day-of-year-deterministic from a 30-entry pool (parallel to TodaysPromptWidget).
  • Cron: hourly tick wired in index.ts, 270s boot delay, heartbeat-tracked as daily_prompt_email_tick. CRON_LABELS updated.
  • Routes (auth): GET /api/user/daily-prompt-preferences returns {opted_in, opted_in_at}; PUT /api/user/daily-prompt-preferences accepts {opted_in: boolean} and writes a timestamp or null.
  • Settings UI: new "Daily nudges" card on /dashboard/settings with a single optimistic toggle (default off). Pattern parallels the existing lovio reminder opt-out.

Personal Activity Feed (/dashboard/activity)

"What's happening for you" across the platform on a single page. Aggregates three signal sources, recomputed from authoritative tables on each request (no persisted notification log — the feed never drifts from source-of-truth state):

  • Lovio capsule events — capsules the user owns where unlocked_at or delivery_email_sent_at landed in the trailing 60 days. "Opened" supersedes "delivered" once both are stamped.
  • New followers on owned shows — joined against story_seasons.owner_user_id; follower display names hydrated from profiles in one batched query.
  • Recent creator earnings — rows from creator_earnings credited to the user, with show title hydrated from the owned-seasons map.

Backend: getUserActivityFeed(userId, limit) in services/user-activity-feed.ts runs the three signal queries in parallel (Promise.all), interleaves results by at timestamp, caps at 50 events. Route: GET /api/user/activity-feed in routes/user.ts. Frontend: /dashboard/activity — kind-coded emoji prefixes (💌 / 📬 / + / 💸), uppercase kind labels, relative-time stamps, each item links to the relevant detail page (capsule detail / season analytics / earnings ledger). Empty state cross-links to the user's lovio + creator dashboards. Nav: "Activity" entry under Journal in FeatureMenu.tsx with a New badge.

Your Numbers (/dashboard/me/stats)

Personal stats page that reflects the user's own journal data back. Pure self-knowledge surface (no leaderboards, no comparisons).

  • Backend: GET /api/journal/profile-stats in routes/journal.ts. Single supabase select (content, mood, tags, mentions, created_at) over the user's trailing 5,000 entries, then in-memory aggregation: total_entries, total_words (whitespace-split), total_days_written (UTC day set), earliest_at, 12-month entry sparkline (padded with zeros for empty months), top 5 moods + tags + @mentions (Map → sorted entries → slice), day-of-week (7-len array, Sun..Sat) + hour-of-day (24-len) frequency arrays.
  • Frontend: apps/frontend/src/app/dashboard/me/stats/page.tsx. Headline stat cards in 4 tones; 12-bar monthly sparkline (gradient amber→rose); three side-by-side rank panels (moods with emoji prefix from MOOD_EMOJI, tags with #, mentions with @); day-of-week + hour-of-day bar charts with a derived "you write most on Xs around H:00" headline.
  • Cap rationale: 5,000 entries keeps the query under 1 MB and the in-memory aggregate fast. Beyond that we'd want a materialized view + cron — flagged as a future scaling step in the route comment.
  • Nav: "Your numbers" entry added under Journal in FeatureMenu.tsx.

Brand-Aware PWA Manifest (/api/manifest)

Every brand (hivejournal.com / graphene.fm / write.cafe) ships its own Web App Manifest so "Add to Home Screen" creates a tile that matches the visited brand.

  • Route: apps/frontend/src/app/api/manifest/route.ts. Reads host header, picks brand via the same normalization as layout.tsx (replace /^www\./ + split by :). Each brand carries name, short_name, description, theme_color, background_color, start_url (brand home), and an icon URL pointing at the existing /api/icons/<brand> 180×180 routes — no extra image generation needed. Returns application/manifest+json with cache-control: public, max-age=300.
  • Layout wiring: manifest: '/api/manifest' + appleWebApp: { capable: true, statusBarStyle: 'black-translucent', title: brand.siteName } added to the per-brand metadata block. Combined with the existing themeColor: '#060610' in the viewport export, iOS shows a fullscreen black-translucent status bar and Android picks up the right theme color on install.

Milestone Celebration Widget

A dashboard widget that surfaces when the user crosses a notable point in their journaling. Pure frontend logic — reuses GET /api/journal/profile-stats, no schema or backend changes.

  • Component: MilestoneWidget.tsx. pickMilestone() chooses the most-recently-hit milestone from the user's stats: first entry (≤ 24h ago), first-week mark (7-10 days since earliest_at), first-month mark (30-37 days), entry-count thresholds (10 / 25 / 50 / 100 / 250 / 500 / 1000 / 2000 / 5000 — surfaces when the user is within 4 entries of the threshold), and year anniversaries (1 / 2 / 3 / 5 / 10 — surfaces when within 7 days of the anniversary date).
  • Per-milestone dismissal via localStorage.hj_dismissed_milestones so a dismissed milestone never re-fires. Tone-coded gradient cards by milestone tier (rose for welcome, emerald for early-day marks, purple/amber for entry-counts, amber for anniversaries).
  • Mounted at the very top of the reflective-widget column on /dashboard, gated by the same !searchQuery && !hasActiveFilters rule as the other widgets so it disappears when the user is filtering.

Global Command Palette (Cmd/Ctrl + K)

A fuzzy-search overlay opens on Cmd/Ctrl + K from any page. Component: GlobalCommandPalette.tsx, mounted at app root via ClientOnlyComponents.tsx so the keyboard shortcut works on /graphene / /seasons / /write-cafe / landing pages, not just /dashboard. (Chrome-free routes /write-cafe/embed + /write-cafe/badge still short-circuit.) The four first-open list fetches (entries / capsules / highlights / notebooks) catch 401 silently so anonymous visitors see just the Quick actions + Pages groups. Section headers (Quick actions / Pages / Notebooks / Recent entries / Lovio capsules / Highlights) appear on the empty-query default view; when the user types, results re-rank by match score and the headers swap for a small right-side group badge.

  • Six result groups: Quick actions (the surprise-me family), Pages (~20 hardcoded nav destinations covering all major routes), Notebooks (every notebook the user owns via /api/notebooks — type the name, jump to /dashboard/notebooks/[id]), Recent entries (last 20 journal entries via /api/journal?limit=20), Sealed lovio capsules (via /api/lovio/capsules), Highlights (last 60 saved pull-quotes via /api/journal/highlights?limit=60, route to source entry). First-open fetches all four list endpoints in parallel via Promise.all and caches them for the session — re-opens never re-fetch.
  • Surprise-me quintet under Quick actions: 🎲 random journal entry (/api/journal/random), 🎲 random highlight (/api/journal/highlights/random — same endpoint the HighlightOfDayWidget's 🔀 shuffle uses), 🎲 random writing prompt (routes to /write-cafe/random-prompt; destination rolls a fresh bento on every mount), 🎲 random Graphene show (/api/story-seasons/public/random — also surfaced by the 🎲 pill in /graphene's catalog header), 🎲 random notebook (client-side pick from the already-loaded notebooks list, no backend call). Each routes to a sensible empty-state landing when the source set is empty (/dashboard/highlights, /graphene, /dashboard/notebooks, etc).
  • Theme toggle under Quick actions: ☀️/🌙 "Switch to {dark|light} mode". Label + icon are render-time derived from the current theme via a themedActions useMemo (so the user sees what the click will do). Dispatch keeps the palette open after flip so the swap is verifiable. Wired through the existing useTheme() hook from @/contexts/ThemeContext.
  • Empty-results "write new entry" fallback: when filtered.length === 0 AND the query is at least 3 characters, a rose-tinted CTA appears: "Write a new entry starting with '<query>'". Enter or click routes to /dashboard/entries/new?prompt=<query> (the composer already accepts the ?prompt= param and pre-fills the content). Turns the dead-end "Nothing matching" into a writing nudge.
  • Substring-token match (lowercase, all tokens must hit) ranked by earliest match position. Top 20 results shown. Empty query shows the quick actions + 8 most-likely page destinations + 5 notebooks + 5 recent entries + 5 recent capsules + 3 recent highlights as a default discovery surface.
  • Keyboard: ↑↓ navigates, Enter goes, Esc closes, Cmd/Ctrl + K toggles. Selected item auto-scrolls into view. Mouse hover updates the selection so click-to-go feels obvious.
  • External-trigger contract: any UI element can dispatchEvent(new CustomEvent('cmd-k:open')) to open the palette without prop drilling. Used by the navbar ⌘K button so touch users (no keyboard) get a path in.
  • Searchable tags + super-admin-gated admin entries (#419): the Result interface gained optional tags?: string[] + superAdminOnly?: boolean. Match-score search now concatenates ${label} ${hint} ${tags.join(' ')} so synonyms surface a result without polluting the visible label (typing "cost", "cron", "expense", "toggle", or "bleed" all surface the Cron toggles UI; "spend" surfaces both LLM spend AND TTS spend; "error" surfaces the error console). Six super-admin-only entries added: cron-controls, llm-spend, system-health, creator-usage, errors, marketing-funnel — filtered via useIsSuperAdmin() from @/lib/useAdminStatus so non-admins never see them in the palette. Gating happens in visiblePages = useMemo(() => PAGE_RESULTS.filter((r) => !r.superAdminOnly || isSuperAdmin), [isSuperAdmin]) — single filter, no per-result branching.

Keyboard Shortcuts Overlay (?)

A small modal listing every global keyboard shortcut, opened by pressing ? (Shift + /) from anywhere outside an input. Component: KeyboardShortcutsOverlay.tsx, mounted at app root via ClientOnlyComponents.tsx so the shortcut works on every page (not just /dashboard). Discovery surface for power users — ⌘K is hinted in the navbar but the on-dashboard letter shortcuts (s/n/⌥F), ⌘+Shift+E, ⌘+Shift+H, and the in-palette navigation keys were invisible otherwise.

  • Same keyboard-shortcuts:open window event contract as the palette so a navbar ? button (sits next to the ⌘K button) can trigger it without binding the key itself.

Quick-Entry FAB

A floating ✏️ button in the bottom-right corner of every authed dashboard page opens a slim modal (title + textarea + save). Save POSTs to /api/journal then navigates to the new entry's detail page so the writer can keep editing if they want to expand. Component: QuickEntryFab.tsx, mounted inside the isAuthenticated block of DashboardLayout.tsx so it never renders on public pages.

  • Keyboard shortcuts: Cmd/Ctrl + Shift + E opens (or closes) the modal from any dashboard page; Cmd/Ctrl + Enter saves the entry from inside the textarea; Esc closes.
  • Self-hides on /dashboard/entries/new (no point doubling up the full composer surface there). Mobile-first: button is fixed bottom-right with env(safe-area-inset-bottom) offset, modal is full-width on small screens + centered card on desktop.

Native OS Share Sheet

Both /stories/[id] "Share this story" button and the PublishStrip "Copy link" button on /seasons/[id] prefer navigator.share({ title, url }) on supported browsers (mobile + recent macOS Safari) and fall back to clipboard copy elsewhere. Uses trackProductEvent with method: 'native_share' | 'clipboard' so funnel splits are visible in analytics.

Share Highlights as Image Cards

Modal that renders a downloadable 1200×630 PNG share card from any highlight. Component: ShareHighlightCard.tsx. Pure canvas rendering — no third-party dependency.

  • Draws a diagonal gradient background tinted by the highlight's color tier (5 hue pairs), an accent bar on the left, the quote in serif italic with shrink-to-fit line layout (layoutQuote() helper walks font sizes from 64→30px until the wrapped output fits 8 lines; falls back with an ellipsis at minFont), the optional note below at 22px, and an attribution + hivejournal.com mark in the bottom row.
  • Two actions: Download (anchor to canvas.toDataURL('image/png')) and Copy image (Clipboard API ClipboardItem({'image/png': blob}), falls back to "use Download" when unsupported).
  • Mounted from a "Share" button on each highlight card in both /dashboard/highlights and the per-entry EntryHighlightsSection. Modal lives in a createPortal(..., document.body) so it escapes any transformed ancestor.
  • Privacy: no highlight ID or content leaves the client — the image is generated locally + downloaded locally. No public URL, no server route, no opt-in needed.

Highlight of the Day Widget

A dashboard widget that surfaces one of the user's saved highlights each day, picked deterministically by UTC day-of-year so it stays stable through the day + rotates at midnight. Self-hides on 404 so dashboards stay quiet for users without highlights yet.

  • Backend: GET /api/journal/highlights/of-the-day in routes/journal.ts — selects all the user's highlights ordered by creation, computes doy % rows.length to pick one, hydrates the source-entry title in a single lookup. Returns 404 when the user has no highlights.
  • Frontend: HighlightOfDayWidget.tsx renders the picked highlight as a colored blockquote card (same 5-tier color palette as the highlights themselves) with a link to its source entry + an "All →" link to /dashboard/highlights. Mounted in the dashboard's reflective-widget column between PinnedEntries + WritingStreak so it lives near the other "your hand-curated stuff" surfaces.

Journal Entry Highlights (pull-quote primitive)

A new highlights table that lets users capture pull-quote passages from their journal — like Kindle highlights but for their own writing.

  • Migration: 225_journal_entry_highlights.sql. entry_highlights (id, user_id, entry_id, text_snapshot, note, color, created_at). entry_id is ON DELETE SET NULL so highlights outlive their source entry; text_snapshot is a copy of the text at save-time so the captured insight survives later entry edits. color is a CHECK-constrained enum (amber/rose/emerald/sky/purple). Two indexes: user-recency for the wall view, partial entry-id for the per-entry section. Owner-only RLS.
  • Routes in routes/journal.ts:
    • GET /api/journal/highlights — wall view newest-first (cap 60 default, max 200), hydrates entry_title in one batched query so the wall doesn't N+1.
    • GET /api/journal/entries/:id/highlights — chronological list scoped to one entry.
    • POST /api/journal/entries/:id/highlights — body { text_snapshot, note?, color? }. Verifies entry ownership before insert; clamps text to ≤ 2000 chars + note to ≤ 500.
    • DELETE /api/journal/highlights/:id — owner-scoped delete.
  • Frontend:
    • EntryHighlightsSection mounted at the bottom of /dashboard/entries/[id] — shows existing highlights as colored blockquote cards + "+ Highlight a passage" button that opens a modal (textarea + optional note + 5-color picker). New highlights append optimistically. Keyboard shortcut: with text selected on the entry detail page, Cmd/Ctrl + Shift + H triggers the same flow as the floating "✨ Highlight this" pill.
    • /dashboard/highlights wall view — 2-up grid of colored cards with source-entry link + relative-time stamp + delete affordance.
  • Nav: "Highlights" entry added under Journal in FeatureMenu.tsx with a New badge.

Pinned Journal Entries (always-within-reach primitive)

"Pin" is the user-curated "keep this within reach" mechanism — distinct from lovio_flagged_at (which is "save this for a future capsule").

  • Migration: 222_journal_entry_pin.sql — adds journal_entries.pinned_at TIMESTAMPTZ + a partial index (user_id, pinned_at DESC) WHERE pinned_at IS NOT NULL so the dashboard widget's "this user's pinned entries newest-first" lookup is cheap.
  • Routes (auth, owner-scoped): PUT /api/journal/:id/pin body { pinned: boolean } (toggles the timestamp on/off); GET /api/journal/pinned returns the trailing 12 pinned entries newest-pin-first.
  • Frontend: PinEntryButton in /dashboard/entries/[id]/page.tsx — 📌 button alongside the existing Share/AI/Delete/LovioHear/LovioFlag row. Optimistic toggle, amber-tinted bg when pinned. Clickable notebook-name chip — the notebook-name chip is now a Next.js Link to /dashboard/notebooks/<id>, so plain click navigates and Cmd/Ctrl-click opens in a new tab natively. Companion PinnedEntriesWidget mounted at the top of the dashboard's reflective-widget column — renders the first 6 pinned entries as compact cards with mood emoji + title + content excerpt + a "+N more" indicator. Self-hides when the user has nothing pinned.

Voice-First Journal Entries (/dashboard/entries/voice)

A parallel composer to /dashboard/entries/new for speaking entries instead of typing them. Captures audio with MediaRecorder (auto-picks audio/webm;codecs=opusaudio/webmaudio/mp4), uploads to a new Whisper-backed endpoint, prefills a transcript + auto-derived title for the user to edit before saving.

  • Page: apps/frontend/src/app/dashboard/entries/voice/page.tsx. Status machine: idle → requesting → recording → transcribing → preview → saving (with error as a recoverable side-state). Preview shows the recording audio inline for re-play, an editable title (derived via deriveTitle() from the first sentence or first 80 chars), and the full transcript in a 10-row textarea. Save POSTs to /api/journal; on success redirects to /dashboard/entries/[id].
  • Backend: POST /api/journal/voice-transcribe in routes/journal.ts. Multipart upload (multer memoryStorage, 25MB cap, single audio field). Forwards the buffer to OpenAI Whisper (/v1/audio/transcriptions, model whisper-1) with optional language hint. Returns { text }. Doesn't create an entry — separating transcribe from save keeps the UX flexible.
  • Nav: added as "Voice Entry" under Journal in FeatureMenu.tsx with badge: 'New'.
  • Combines with the lovio "hear in my voice" feature for a full audio loop: speak → transcribe → save → hear it back in your cloned voice → seal into a future capsule. Requires OPENAI_API_KEY (already configured for other GPT features).

Dashboard Reflective Widgets (streak / on-this-day / prompt / mood snapshot)

A column of self-knowledge widgets above the entries list on /dashboard. All self-hide when search or any filter is active (so the dashboard becomes a focused entries-grid in filter mode) and tolerate no-data gracefully. Order: WritingStreak → DailyComic → OnThisDay → TodaysPrompt → MoodSnapshot.

  • WritingStreakWidget — 🔥 streak count + 30-day calendar heatmap. UTC-keyed streak math (mirrors write-cafe pattern). When at-risk, a one-tap mood row appears (happy/calm/grateful/excited/tired/sad/anxious/frustrated) so the user can save the streak with a single tap. Backend: GET /api/journal/streak.
  • OnThisDayWidget — surfaces past entries from same MM-DD across previous years. Backend: GET /api/journal/on-this-day.
  • TodaysPromptWidget — daily writing-prompt nudge. Two modes:
    1. Day-of-year rotation (default) — same prompt for everyone on the same UTC calendar day, drawn from DAILY_PROMPTS[64]. Idempotent across refreshes.
    2. Mood-adaptive — reads GET /api/journal/mood-stats?days=7; when one mood is dominant (>=40% share + >=3 tagged entries), swaps to a mood-tuned prompt from MOOD_PROMPTS[mood] (4-6 per mood: anxious/tired/sad/frustrated/happy/calm/grateful/excited). Card recolors via MOOD_TIER and caption flips to "For where you've been · <mood>". Pick within the mood pool is still day-of-year deterministic so it doesn't flicker. Both modes link to /dashboard/entries/new?prompt=<encoded> to pre-fill the composer.
  • MoodSnapshotWidget — 30-day mood distribution as a stacked horizontal bar (each mood gets a color-tinted segment proportional to its count) + emoji-only legend. Self-hides when total_tagged_entries < 3. Each segment + chip is a click-to-filter button: clicking sets the dashboard's filterMood state, which propagates to GET /api/journal?mood=<m> (server-side) AND the client-side filteredEntries memo. Active-mood pill renders above the entries list with one-click clear. Backend: GET /api/journal/mood-stats?days=N returns { days, total_tagged_entries, distribution[] } (distribution sorted by count desc; each row { mood, count, percent }).

Dashboard Search + Filter Chips

/dashboard has an open-on-Ctrl/Cmd-K search bar that narrows entries by text AND four rows of clickable filter chips — all combine with AND:

  • Recency three-way toggle: All time / Last 30 days / Last 7 days (primary-indigo when active, always visible)
  • Notebook top 8 by entry count (purple chips)
  • Tag top 12 by frequency, excluding visual-only tags like Patent/Experiment (emerald chips, # prefixed)
  • Person top 10 @mentions by frequency (blue chips, @ prefixed)

Text input is debounced 350ms before firing a server refetch; chips fire immediately. "Found N · Clear all" row appears whenever any filter is active.

Server-side filteringGET /api/journal accepts q, tag, person, mood, since_days, and notebook_id query params (plus the pre-existing limit/offset). Filters compose with AND. Pagination threads the filters through every load more, so filtered results scroll across the full matching set — not just the already-loaded window. mood does an equality match against the entry's mood column (lowercased) — wired from the MoodSnapshotWidget's click-to-filter.

  • Backend: apps/backend/src/routes/journal.tsq uses ilike on title+content with %/_ escaped; tag uses .contains(tags, [tag]); person uses .contains(mentions, [name]); since_days uses .gte('created_at', cutoff).
  • Frontend: apps/frontend/src/app/dashboard/page.tsx — facet useMemo derives chip lists from loaded entries; a second useEffect (guarded by filtersDidMount ref) refetches on any filter change.
  • Side-stream gating: when any dashboard filter is active, the satisfaction / action / sleep supplementary fetches are skipped (they don't carry notebook / tag / mention data, so mixing them into a filtered view would pollute it). Clear filters to restore the mixed feed.

Journal Entries: @mentions column

Parallel to user_tasks.mentions (migration 065), journal_entries now has a mentions TEXT[] column with a GIN index (migration 071). Backend auto-extracts on POST + PUT using the same regex the tasks endpoint uses ((^|\s)@[a-zA-Z0-9_-]{1,50}, lowercased, deduped, no sigil, email-safe).

  • Extractor helper: extractMentionsFromEntry() in apps/backend/src/routes/journal.ts
  • PUT handler selects content off the existing row so partial updates (title-only or content-only) don't drop mentions
  • One-shot backfill for rows created before migration 071: POST /api/journal/backfill-mentions processes up to 500 per call, returns { updated, remaining } — caller loops until remaining === 0
  • Powers the dashboard person chip via .contains('mentions', [name]) (GIN-indexed, no ilike substring scan, no false positives from emails)

"Other" pseudo-notebook (orphan entries)

Journal entries written without a notebook assignment show up in an Other card on /dashboard/notebooks (conditional on there being at least one orphan). Clicking it opens /dashboard/notebooks/other, which renders those entries with the same list layout and note ↔ task swap behavior as a real notebook's detail page.

  • Backend: GET /api/journal?notebook_id=null (string literal) filters by .is('notebook_id', null). none works too. Existing real-UUID notebook_id queries are unchanged.
  • Frontend: apps/frontend/src/app/dashboard/notebooks/page.tsx fetches orphans with useJournalEntries({ notebook_id: 'null', limit: 1 }) and reads total from the response to decide whether to render the card. apps/frontend/src/app/dashboard/notebooks/other/page.tsx is the detail route.

Notebooks

Group entries into named notebooks with their own visibility settings.

  • Frontend: apps/frontend/src/app/dashboard/notebooks/
  • Backend: apps/backend/src/routes/notebooks.ts
  • Migrations: 002_add_notebooks.sql
  • Routes: /dashboard/notebooks, /dashboard/notebooks/new, /dashboard/notebooks/[id]

Index search + type filter + sort (persisted). The notebooks index has a search input (substring match across name + description; self-hides under 6 notebooks), a type-filter pill row (All / 📖 Stories / Notebooks; only renders when the user has a mix), and a <select> with five sort orders (most active / newest / oldest / A→Z / most entries). Sort runs after filtering on a shallow copy so the unfiltered list reference stays stable. All three pieces of state persist to localStorage under hj:notebooks-filters-v1 via the filter persistence pattern — same shape as hj:highlights-filters-v1, hj:admin-seasons-filters-v1, hj:stream-filters-v1. Code in apps/frontend/src/app/dashboard/notebooks/page.tsx.

Inline entry search on the detail page. /dashboard/notebooks/[id] has a search input that filters the entries grid by title + content substring. Graphic-novel notebooks are narration-aware — the substring match runs across the narration text too, not just panel metadata, so a reader-mode search for a remembered line lands on the right page. The input is hidden in 'read' mode (where the page acts as a story reader rather than an entry list). Frontend: apps/frontend/src/app/dashboard/notebooks/[id]/page.tsx.

Visibility System

Per-entry and per-notebook control over who sees metadata vs content (public, team, org, user).

  • Backend: apps/backend/src/database/visibility.ts
  • Migration: 004_add_visibility_options.sql
  • Reference: docs/VISIBILITY_SYSTEM.md

Entry Sharing & OG Cards

Generate public share links for entries with auto-generated Open Graph preview images.

  • Frontend: apps/frontend/src/app/share/[shareId]/page.tsx
  • OG image: apps/frontend/src/app/api/og/[shareId]/route.tsx
  • Backend: share endpoint inside journal.ts
  • Migration: 024_add_entry_sharing.sql

Stream View

Visual timeline of entries with custom backgrounds. The "feed" version of the dashboard.

  • Frontend: apps/frontend/src/app/dashboard/stream/page.tsx
  • Route: /dashboard/stream

Tracking & Wellness

Satisfaction Quick Values

Lightweight 0-10 satisfaction logging with optional emotion + energy + strength.

  • Backend: apps/backend/src/routes/satisfaction.ts
  • Migrations: 019_add_satisfaction_quick_values.sql, 021_add_energy_and_emotion_strength.sql, 044_add_journal_entry_id_to_satisfaction_quick_values.sql
  • Surfaced via the dashboard slider

Sleep Tracker

Daily hours-slept logging in 0.5-hour increments.

  • Backend: apps/backend/src/routes/sleep.ts
  • Migration: 023_add_sleep_tracker.sql

Action Notes (Quick Activities)

Log discrete activities (exercise, learning, work, etc.) with category and optional description.

  • Backend: apps/backend/src/routes/actions.ts
  • Migration: 022_add_action_notes.sql

Health Actions & Recommendations

Track wellness actions (hydration, walks, stretches, meditation, wind-down) and receive rules-based recommendations.

  • Backend: apps/backend/src/routes/health.ts
  • Migration: 045_health_actions_and_recommendations.sql
  • Reference: docs/hivejournal-health-actions-pack/

AI & Insights

Chatbot (JQ AI Companion)

Multi-turn AI conversations grounded in the user's own journal entries. Persists conversations + messages.

  • Backend: apps/backend/src/routes/chat.ts
  • Migrations: 035_add_chat_system.sql, 036_add_conversation_metadata.sql, 037_add_chatbot_settings.sql
  • Cron: apps/frontend/src/app/api/cron/chat/analyze-notes/route.ts
  • Generation pipeline (services/chat/chat-service.ts) routes through the shared LLM shim complete() rather than calling OpenAI directly — picks up automatic per-call cost logging to llm_call_log (feature_keys chat_assistant and chat_assistant.tool_followup, visible on /dashboard/admin/llm-spend), provider-fallback retry on transient OpenAI errors (no-tools path), and the shared model_key catalog. Super-admin chats attach two function tools — spawn_universe_batch (kicks off a full auto-universe batch ~$1) and spawn_show_in_universe (one novella inside an existing universe ~$0.05-0.15). Tool-execution errors get surfaced both to Railway logs AND service_errors (service=chat-tool) so admins can grep without re-reading the user's chat. Non-admins never see tools attached.

JQ canon mode (conversational canon keeper)

A chat conversation scoped to a universe or season (nullable chat_conversations.canon_universe_key / canon_season_id, migration 417; plus canon_episode_id, migration 447, to ground in one open chapter's prose). Set at create time via POST /api/chat/conversations; the frontend fires a jq:open-canon CustomEvent to open a scoped chat. Access (A1, 2026-07-03): own-or-super for SEASON scope (creators get canon on stories they own), super-admin-only for UNIVERSE scope — enforced by canAccessCanonScope at all three seams (conversation create, generateChatResponse, executeCanonTool). Design: docs/product/JQ_CANON_KEEPER.md. services/chat/canon-mode.ts:

  • Phase 1 (read)buildCanonContext(scope) injects the scoped bible/universe canon (characters, settings, planted threads, motifs, themes, voice/tone/do-not-use) into JQ's system prompt (capped 14k chars); for a season scope with canon_episode_id, the open chapter's live prose is injected too (capped 8k, placed high, validated ep.season_id === seasonId so a stale id can't leak another story). Canon mode also bumps the model gpt-4o-mini → Sonnet (WRITER_QUALITY_MODEL_KEY, since the llm.ts shim gained Anthropic tool-calling so the canon_* tool loop runs provider-portably; Anthropic outages fall back to OpenAI via PROVIDER_FALLBACK). Proactive continuity nudge (2026-07-10): a season-scoped buildCanonContext also folds a compact computeCanonHealth summary (top findings + counts, or "canon is clean") into JQ's system prompt so JQ offers the top issues early in the conversation rather than waiting to be asked — reuses the bible already loaded (no re-fetch), best-effort (never blocks context).
  • Phase 2 (edit, season scope only)CANON_WRITE_TOOLS: canon_add_character / canon_update_character / canon_add_thread / canon_update_thread / canon_add_motif / canon_update_summary, plus (2026-07-03) canon_add_setting / canon_add_theme / canon_add_voice_rule / canon_add_tone_rule / canon_add_avoid. canonToolToDelta() (pure, golden-tested canon-mode.test.ts) maps each call to a BibleDelta; applyDelta() appends settings/themes deduped by key and voice/tone/do-not-use strings deduped case-insensitively (via commitBible's field merge, so untouched sections are preserved); executeCanonTool() commits — additive/update-only, versioned + revertable (roll back from the bible History tab), gated own-or-super.
  • Phase 3 (organizer)canon_health tool → computeCanonHealth(seasonId) (the shared season audit reused by the canon_health tool, the proactive canon-mode nudge, and the GET /:id/canon-health route) → analyzeCanonHealth(bible, {latestChapter}) (pure, golden-tested canon-health.test.ts): deterministically flags planted threads overdue (past payoff chapter, still open) or with no payoff chapter set, active characters with no arc, and motifs planted-but-never-paid-off — so JQ keeps canon tidy, not just edits on command. Cross-story consistency guardcanon_consistency tool (UNIVERSE scope) → getUniverseConsistency(key) gathers the universe's published stories' bibles and runs the pure findCanonConsistencyIssues() (golden-tested canon-consistency.test.ts): flags characters shared across ≥2 stories that contradict on an IMMUTABLE fact — different MBTI, different pronouns, or a diverged display name under the same key (deliberately NOT status/arc, which legitimately evolve; matches by key, else normalized name). getCanonTools(scope) returns [canon_health, ...write tools] for season scope, [canon_consistency] for universe scope, [] otherwise. Dispatch: names starting canon_ route to executeCanonTool (chat-service.ts). Also surfaced outside chat: getUniverseCanonAudit(key) runs the same analyzers (consistency + per-story health) behind GET /api/story-seasons/universes/:key/canon-audit (super-admin), rendered as a lazy "🔎 Canon audit" panel (UniverseCanonAudit.tsx) on /universes/[key] — so a writer sees canon issues in the UI without opening a canon-mode chat.

Note Analysis (Tone, Mood, Energy)

Background OpenAI analysis of journal entries to extract tone, mood, sleep quality, and energy. Stored in note_analyses for trend visualization.

AI Background Image Generation

DALL-E generated background art for journal entries based on title/content/tags.

  • Backend: apps/backend/src/routes/ai.ts (generate-background)
  • Frontend: image generation flow in entry editor

AI Quotes

Inspirational quote fetching via ZenQuotes API.


Social & Connections

JQ Bridge (User-to-User Connections)

Invitation-based system for users to connect and share select entries with each other. Supports permission tiers.

  • Backend: apps/backend/src/routes/jq-bridge.ts
  • Frontend: apps/frontend/src/app/dashboard/jq-bridge/ · accept/[token]
  • Migrations: 039_add_jq_bridge.sql, 042_allow_jq_system_drops.sql
  • Routes: /dashboard/jq-bridge, /dashboard/jq-bridge/invitations, /dashboard/jq-bridge/my-connections, /jq-bridge/accept/[token]
  • Concrete illustration: the "See exactly what you'd see" demo (ThroughlineDemo on /for-therapists) shows a full metadata-only share side by side (entries vs. the trends-only view a contact receives). Cross-linked from the public /jq-bridge page + the Features catalog JQ Bridge entry. Also on the dashboard as BridgeShareExample (collapsible "what your connections see vs. what you journal", reuses GET /api/providers/demo-clients). See the Throughline section below.

Throughline (therapist-facing service, codename JQ Connect)

"Throughline, by HiveJournal" — between-session visibility for therapists, built on JQ Bridge (connection_type='healthcare'). Clients share metadata-only trends, never entries. Phase 1 = public landing + design-partner lead capture. Full plan + monetization thesis (free / evidence-as-asset / disability-insurer wedge): docs/product/THROUGHLINE_PROVIDER_PLAN.md.

  • Landing: /for-therapists (+ layout.tsx metadata)
  • Demo ("See exactly what you'd see"): ThroughlineDemo.tsxGET /api/providers/demo-clients ← curated fixture apps/backend/src/data/throughline-demo-clients.ts (AI-persona journals + their therapist-facing analysis; trends only, no recommendations)
  • Backend: apps/backend/src/routes/providers.tsPOST /api/providers/interest (public lead capture), GET /api/providers/demo-clients (public), POST /api/providers/demo-clients/seed (super-admin — regenerate the demo through the real pipeline), GET /api/providers/interest (super-admin)
  • Demo generator (optional, refreshes the fixture with real model output): services/throughline-demo-seed.ts creates NON-public personas → real back-dated generateJournalEntry() → a provider-framed (3rd-person, trends-only) read → auto-cleanup. Driven by scripts/throughline-demo-clients.ts (--dry-run / AUTH_TOKEN=… npx tsx …); paste its output into the fixture. Runs server-side because importing backend services into a script boots index.ts + every cron.
  • Lead review: /dashboard/admin/provider-interest · Migration: 363_provider_interest.sql
  • Audience landings — dedicated leads + per-audience funnel (task bd75f165): the non-therapist /for-<audience> landings (english teachers, science teachers, teachers; guides/coaches keep coach_interest) no longer pool into provider_interest — leads live in audience_interest (migration 492, with a backfill that MOVES the old for_% rows over). Routes routes/audience.ts at /api/audience: public POST /interest (same payload as the old pooled endpoint — the shared DesignPartnerForm only swapped endpoint; audience derived from source) + POST /landing beacon; super-admin GET /funnel + GET /interest. Funnel events ride the generic funnel_events with funnel='for_audience', surface=<audience> via services/audience-funnel.ts (aggregateAudienceFunnel pure + golden-tested; normalizeAudience slugging). The coaching POST /coach-interest also fires an interest_submitted event so guides/coaches are funnel-visible without moving their leads. Client beacon: AudienceLandingBeacon in components/landing/index.tsx, rendered by all five audience pages. Readout: /dashboard/admin/audiences (per-audience landings/interest/conversion over 7–365d + filterable leads).
  • OG card: /api/og/throughline
  • Evidence layer — consented validated self-report (the outcome instrumentation the business model turns on; plan §"Funnel & outcome instrumentation"). A user opts into a short, VALIDATED wellbeing/anxiety check-in on their own cadence, deterministically scored + stored with full consent provenance so a consented cohort is a defensible dataset (not just logs). Instruments are code-defined + versioned in services/self-report.ts: WHO-5 (featured — positive wellbeing, no symptom/risk items) + GAD-7 (validated anxiety; deliberately NOT PHQ-9 — we don't ship its self-harm item, keeping the wellness-not-clinical posture). scoreSelfReport() is pure + golden-tested (self-report.test.ts — WHO-5 ×4 normalization + 12/13 low-wellbeing seam, GAD-7 5/10/15 band seams, validation throws). Storage: migration 441 self_report_responses (user-owned RLS, append-only, total_score/max_score/band + consent_version/consent_text/consented_at provenance + timestamps). Routes routes/self-report.ts (/api/self-report): GET /instruments(+/:key) public catalog, POST /respond (authed, consent-gated → scored server-side), GET /responses (caller's own timeline). UI: opt-in /dashboard/check-in — pick a check-in, answer, consent, see score + a per-instrument trend (summarizeSelfReportTrend() — pure, golden-tested, improvement-AWARE so a lower GAD-7 and a higher WHO-5 both read "improving"; GET /api/self-report/trend) + history; wellness-not-clinical disclaimer on every screen, no diagnostic labels shown. Provider read-access (shipped): a new JQ Bridge permission wellbeing_checkins (migration 442) lets a client share that self-report TREND (scores + direction + when, never entries or item answers) with a consenting contact — surfaced in the contact's my-connections analysis view via GET /api/jq-bridge/connections/:id/analysis. Defaults OFF (unlike the six metadata permissions that default on) — a validated mental-health measure is the right place to be conservative; the sharer opts in per connection (per-permission defaultEnabled in the permissions UI). Acquisition funnel (shipped): first-party throughline_provider funnel in funnel_events (survives ad-blockers, unlike PostHog) via services/provider-funnel.ts — stages landing (a beacon POST /api/providers/funnel-landing from /for-therapists) → interest_submitted (the design-partner form) → invitation_sent / invitation_accepted (a healthcare JQ Bridge invite created/accepted) → client_viewed (a provider opening a client's analysis). All fire-and-forget. aggregateProviderFunnel() is pure + golden-tested (landing dedupes by session token, authed actions count per-row); readout GET /api/providers/funnel (super-admin) renders as a stage strip on /dashboard/admin/provider-interest. Access audit + transparency (shipped): migration 443 provider_access_log (append-only who-viewed-what-when; RLS lets only the data OWNER + the viewer read their own rows, server-only writes) — the plan's "cheap BAA hygiene." Every GET /connections/:id/analysis view appends a row (viewer, subject, permissions_viewed, ip_hash) via services/provider-access-log.ts; GET /api/jq-bridge/access-log gives the sharer a transparency feed ("who's viewed your shared data") on /dashboard/jq-bridge, collapsed by the pure golden-tested collapseAccessViews() so a viewer's rapid refreshes read as one access. Reminder cadence (shipped): opt-in periodic nudges so the longitudinal series actually accumulates — migration 444 adds profiles.wellbeing_checkin_reminder_opted_in_at / _cadence_days / wellbeing_checkin_last_reminder_at; the wellbeing_checkin_reminder cron (6h, services/wellbeing-checkin-reminder.ts, heartbeat + CRON_REGISTRY toggle + WELLBEING_CHECKIN_REMINDER_ENABLED, OFF by default) emails opted-in users whose last check-in is older than their cadence, never more than once per cadence. Pure isReminderDue() is golden-tested; one-click HMAC unsubscribe (GET /api/self-report/reminders/unsubscribe); opt-in toggle on /dashboard/check-in via GET/PUT /api/self-report/reminders. Engagement-over-time leading indicators (part 2, shipped): a second opt-in JQ Bridge permission engagement (migration 451, defaults OFF like wellbeing_checkins) shares journaling cadence — the "is the client still showing up?" signal that moves BETWEEN the sparser scored check-ins. Metadata only, derived purely from entry TIMESTAMPS (never content or mood): trailing-30d entry count + active days, current/longest streak, an 8-week consistency ratio, days-since-last-entry, and a rising/falling/steady direction (recent-30d vs prior-30d volume, ±20% dead-band). computeEngagement() in services/journal-engagement.ts is pure + golden-tested (journal-engagement.test.ts — streak grace-day/lapse, same-day collapse, longest-run, consistency buckets, direction seams); getEngagementSignal() folds the last ~63 days of journal_entries and returns null (section omitted) when there's nothing to share. Surfaced in the contact's my-connections view via the same GET /api/jq-bridge/connections/:id/analysis path (permission toggle in the permissions UI). Self-view (see-what-you-share): the sharer previews their own cadence on the JQ Bridge dashboard via MyEngagementCardGET /api/jq-bridge/my-engagement (authed, the caller's own getEngagementSignal) — transparency + a little self-motivation, next to BridgeShareExample. Still deferred: part-3's cohort→buyer-metric linkage (needs a real pilot).

Rehearsal Room (decision-testing tool)

Model a person (free-text description, or an optional archetype) + a situation and 2–3 options → modeled in-character reactions, side by side. The AI-persona engine pointed sideways (reuses MBTI_WRITING_STYLES + the cost-logged complete() shim). Honesty: a rehearsal, not a prediction. Full plan + verticals + roadmap: docs/product/REHEARSAL_ROOM.md.

  • Migrations: 364_rehearsal_room.sql (rehearsal_runs, rehearsal_rate_limits), 365_rehearsal_room_tags_public.sql (tags[] + public-by-default + GIN/public indexes), 366_rehearsal_personas.sql (saved-people library).
  • Public, anonymized by default + searchable gallery: before modeling, prepareForPublic swaps real person names → consistent pseudonyms + generates topic tags (modeling runs on the sanitized inputs); UI warns + shows the swaps. Writer mode skips name-swap (fictional names) via tags-only generateTags. GET /gallery?q=&tag= merges public runs + curated examples. "Keep this private" opt-out stores owner-only with real names (no slug, not in gallery; share endpoint refuses to publish it).
  • Backend: routes/rehearsal-room.ts, services/rehearsal-room.ts, gallery fixture data/rehearsal-gallery.ts. Endpoints (mounted /api/rehearsal-room): POST /run (public, throttled; mode, remember, keep_private), GET /runs · GET /runs/:idOrSlug · POST /runs/:id/share · PATCH /runs/:id · GET /archetypes · GET /gallery · GET/POST /personas · DELETE /personas/:id · POST /claim (anon→account). Admin kill-switch: site_settings.rehearsal_room_throttle.
  • Frontend: config-driven engine RehearsalTool.tsx (auth-aware — sends the token so signed-in data keys by user_id; claims anon runs/people on mount) shared by 3 verticals: /rehearsal-room (life), /rehearsal-room/work (manager/colleague/report), /rehearsal-room/writers (mode='writer', + a DeepCutCharacterRotator hero figure + WritersCrossSell — story-universe capture that seeds the composer via ?prompt= into HiveJournal/write.cafe + Graphene). Components rehearsal-room/: RehearsalResults / History / Gallery (search+tags) / StageBackground / RehearsalCharacterPanel (the write.cafe plugin — embedded in the entry composer's options drawer, "use my draft as the scene"). Saved people ("remember this person/character") + picker. Each page has its own layout + OG (/api/og/rehearsal-room + …/writers + …/work); shared/[slug] renders saved + gallery runs (mode-aware). Discovery: Navbar "More", Features catalog, /for-therapists cross-link, /write-cafe hero CTA, inter-vertical cross-links.
  • Characters campaign + writer-pipeline promo (growth): higher-conversion landing page /rehearsal-room/characters ("what would your lead character do?") — same mode='writer' engine as /writers, sharper hook, + a public multi-platform promo generator so writers pass it on. services/rehearsal-writer-promo.ts drafts posts for Bluesky / X / Threads / Reddit / Tumblr (per-platform char limits + culture). Endpoints: POST /api/rehearsal-room/writer-promo (platformKey/angleKey/angle/tone/variants) + GET /writer-promo/options. Public + IP-throttled (own bucket); super-admins get variants + free-text angle. Shared UI WriterPromoGenerator.tsx (variant="public" on the landing page, variant="admin" at /dashboard/admin/writer-promo). feature_key rehearsal_room.writer_promo.<platform>. Admin variant also has the per-draft 🖼️ Image button (shared POST /outreach/imagegeneratePostImage, theme inferred from the draft).
  • Dialogue voice-check (second writer funnel): standalone tool + landing page /rehearsal-room/voice — "is your character's dialogue consistent with their personality? (MBTI/OCEAN)". services/rehearsal-dialogue-check.ts grades a pasted dialogue snippet against the character (grounded in MBTI_WRITING_STYLES): read (consistent/mixed/off) + what rings true / drifts off-type / how to sharpen + inferred type. POST /api/rehearsal-room/dialogue-check (+ GET /dialogue-check/options for the MBTI menu and the curated samples). UI DialogueConsistencyTool.tsx; funnels into /characters + signup. The promo generator gained a target (characters | voice) so posts can point at either page. feature_key rehearsal_room.dialogue_check. "Try a character": SAMPLE_CHARACTERS (5 real characters from public Graphene universes — Turing Logs / Deep Cut — each with a hand-authored in-voice line) one-tap-fill the tool (per-character dialogue isn't queryable from prose, hence curated). Tiered limits (the upgrade ladder): anon DIALOGUE_CHECK_ANON_PER_DAY=5/day (per-IP), signed-in free 25/day (per-user counter), Graphene+ (isGrapheneSubscriber) unlimited — checkDialogueLimit() returns 429 code:'dialogue_limit' + tier, and the UI shows the matching nudge ("Sign in for more" / "Go Graphene+"). All counters share rehearsal_rate_limits. Test your OWN characters (signed-in): GET /dialogue-check/my-characters (auth) pulls the caller's characters from their seasons' season_story_bibles.characters (name/voice_brief/verbal_tics/mbti → description); the tool shows a "Your characters" chip row that fills the description but NOT the dialogue (you paste a line they say). Anon users see a locked teaser ("🔒 Test your own story's characters → Sign in") instead — the signed-in/creator upgrade hook. Browse ANY public character (public, no auth): GET /dialogue-check/public-characters returns characters from every PUBLISHED season's bible (is_published_to_graphene + active/completed), rendered as a "From any public story" chip row (with a filter box once the catalog is long) — so the picker isn't limited to the curated 5. Both my-characters + public-characters share one pure, golden-tested projection extractPickerCharacters() in rehearsal-dialogue-check.ts (test) so the two pickers can't drift.
  • Story-home hosting (writer retention): after a run, WritersCrossSell.tsx nudges the writer to name the story + say what it's about and "give your story a home" — persists a lightweight, anon-friendly story_projects row (migration 378_story_projects.sql; claimed on signup via the extended POST /claim) and seeds the HiveJournal/write.cafe hand-offs with the full title+about+character context. Endpoints: POST /api/rehearsal-room/story-project, GET /story-projects, PATCH/DELETE /story-project/:id. Hosted stories surface at /dashboard/stories (list + develop hand-offs + remove). Deliberately NOT the heavy story_universes/seasons machinery — it's the on-ramp, not the studio.
  • Funnel attribution (measure): first-party, server-side stage tracking that survives ad-blockers. services/writer-funnel.ts records landing (via POST /funnel/landing from FunnelPing.tsx) + tried (server-side at /run and /dialogue-check, tagged by surface); hosted + signed_up are derived exactly from story_projects (migration 379_funnel_events.sql). Read-out: GET /api/rehearsal-room/writer-funnel?days=N (super-admin) → dashboard /dashboard/admin/writer-funnel: visits → tried → hosted → signed-up per surface (characters/voice/writers).
  • Per-item share cards + spotlight backgrounds: shared runs (/rehearsal-room/shared/[slug]) now render a per-item OG card from the run's own title/persona/situation — dynamic route /api/og/rehearsal-room/shared/[slug], wired via the [slug]/layout.tsx generateMetadata. For runs worth highlighting, a super-admin can generate an LLM-relevant atmospheric background (services/rehearsal-og-images.ts, gpt-image-1 → season-assets/rehearsal-og/, migration 380_rehearsal_og_image.sql) via POST /runs/:id/og-image (+ DELETE to clear) from /dashboard/admin/rehearsal-highlights or an inline super-admin button on the shared page itself (shared/[slug]/page.tsx, gated by useIsSuperAdmin). Instead of paying the image model, the admin can pick one of the in-rotation stage backgrounds (stage.webp/stage-work.webp/stage-characters.webp/stage-voice.webp) — the POST body takes a stage key (STAGE_BACKGROUNDS in the service, mirrored in frontend lib/rehearsalStages.ts); the backend fetches the live .webp, transcodes it to a 1200×630 PNG with sharp (next/og can't decode webp) and re-hosts it in the same bucket, so it's free + on-brand. :id is a run uuid for real shares, or a curated gallery slug for showcase examples — those store their background in rehearsal_share_og (migration 381_rehearsal_share_og.sql, slug-keyed, since fixtures have no rehearsal_runs row), merged into GET /runs/:idOrSlug. Reader-style page: when a spotlight background is set, shared/[slug]/page.tsx renders a full-screen image hero (title + persona/situation) that the content scrolls up over (RehearsalReaderHero.tsx, rAF fade/scale). Produced audio reading (super-admin): POST /runs/:id/audio (+ DELETE) → services/rehearsal-audio.ts builds a multi-voice script (narrator frames; the persona's stable-hashed VOICE_BANK voice speaks the modeled reactions), renders via ttsSegment + concatMp3Buffers + uploadAudioseason-assets/rehearsal-audio/, stored on rehearsal_runs.audio_url / rehearsal_share_og.audio_url (migration 385_rehearsal_audio.sql), spend logged under feature_key rehearsal_room.audio (~$0.20 each). A public 🎧 "Listen" player shows on the shared page when audio_url is set.
  • Scene Studio (super-admin, /dashboard/admin/scene-studio): first step toward a movie-creation studio (design). Turns a Graphene chapter into a dramatized animatic: services/scene-studio.ts generates a shot breakdown (gpt-4o → ordered shots {visual_prompt, shot_type, speaker, dialogue, duration} + style_anchor), a keyframe still per shot (Flux 16:9 via Replicate, gpt-image fallback), then assembles the animatic — per-shot Ken Burns over the keyframe (ffmpeg zoompan) timed to the shot's dialogue rendered in the speaker's voice (ttsSegment, stable-hashed VOICE_BANK; narrator otherwise), concatenated into a 1920×1080 MP4 in season-assets/scene-studio/. Tables scene_projects + scene_shots (migration 387_scene_studio.sql, one project per chapter, keyframe_url/audio_url/clip_url per shot). Routes scene-studio.ts: GET /chapters, GET/POST/DELETE /projects/:episodeId, POST /projects/:episodeId/{breakdown,keyframes,assemble}. Picker (GET /chapters via listScenableChapters): returns a universe → story → chapter tree (ScenableUniverse[]) — seasons grouped by story_seasons.universe (joined to story_universes for label/icon; null = "Standalone"), each season carrying mode/genre/status + derived tags for search and an in_progress/last_activity signal (from scene_projects.status/updated_at). Universes + seasons with an in-progress scene (or the most recent activity) sort to the top. Frontend renders a collapsed-by-default tree (in-progress branches auto-expand) with an amber activity dot, per-chapter scene-status badge (amber while WIP, emerald at final), and a search box matching universe/story/chapter/tag (force-expands matches).
  • Reverse Screenplay Engine (owner-or-admin since task e1560971 — creators adapt their OWN novellas via season/scene-scoped gates on every route + an owner-filtered GET /seasons; admin UI at /dashboard/admin/screenplay): the missing "script" layer in story→script→movie (design) — adapts a finished novel-mode novella into a screenplay. Phase 1 (beat sheet) is built: services/screenplay-engine.ts generateBeatSheet runs ONE planning-tier call (PLANNING_MODEL_KEY = claude-sonnet-4-6, feature_key screenplay.beat_sheet) over every prose chapter's structured metadata (dramatic_role + the knowledge layer reader_knows_entering/chapter_reveals/still_withheld + chapter_stakes + a capped prose excerpt) plus the story bible (buildBiblePromptBlock), and emits an ordered list of screenplay SCENES re-segmented by time+place — each with a slug line, beat_role, scene_purpose (the turn), knowledge_reveal, source_chapter_numbers (provenance), an adaptation_decision (keep/compress/merge/invent_connective/cut_context), and a page_estimate summed against the project's target_pages compression budget (the single biggest knob; ~25 short / ~100 feature). Thesis: an adaptation engine, not a formatter — it reads the metadata, not just prose, so it never re-derives intent/stakes the bible already holds. Phase 2 (per-scene Fountain render) is also built: generateAllScenes (and per-scene generateSceneFountain) render each beat into actual Fountain screenplay prose with Haiku (DEFAULT_MODEL_KEY, feature_key screenplay.scene_fountain), folding the two hard adaptation passes into one prompt — externalize interiority (thought → action/behavior/subtext/sparing VO, so the scene's knowledge_reveal lands) and surface dialogue in voice (grounded in the bible's voice_brief/verbal_tics/speech_avoid) — over the scene's source_chapter_numbers prose. Cached per scene (only empties render unless force), bounded concurrency 4, failures isolated; flips status → scenes when all bodies exist. assembleFountain builds the canonical doc on demand (Fountain title page + === + ordered scene bodies) for download. Tables screenplay_projects (one per season, unique season_id) + screenplay_scenes (fountain_body now populated in phase 2) — migration 411_screenplay_engine.sql. Routes screenplay.ts: GET /seasons (novel-mode picker), GET /projects/:seasonId, POST /projects/:seasonId/beats ({target_pages, force}), PATCH /projects/:seasonId, POST /projects/:seasonId/{scenes,reorder,render}, GET /projects/:seasonId/fountain (assembled doc), PATCH/DELETE /scenes/:sceneId, POST /scenes/:sceneId/fountain (render one). Phase 3 (quality layer) is built (services/screenplay-critique.ts, migration 412_screenplay_critique.sql): critiqueScene runs a multi-lens editor panel (Sonnet, feature_key screenplay.critique) over a rendered scene — structure_editor / scene_economy / on_the_nose_doctor / voice_distinction / vo_discipline / white_space / continuity_canon — storing findings (screenplay_scene_critiques, severity nit/minor/major, replaced wholesale per re-critique). improveScene folds those findings into a re-render (generateSceneFountain gained revisionNotes) then clears the now-stale findings — the apply-and-improve loop. evaluateFidelity (Sonnet, screenplay.fidelity) grades the assembled screenplay against the SOURCE on a rubric (throughline / planted-thread payoffs / knowledge-layer reveals / arc / voice), 0–100 + per-dimension scores + concrete gaps, stored on screenplay_projects.fidelity_score/fidelity_report — the guardrail against a polished script that lost the story. getScreenplayProject now attaches per-scene critiques[] + the project fidelity report. Routes: POST /scenes/:id/{critique,improve}, POST /projects/:seasonId/fidelity. Phase 4 (export + handoff) is built (services/screenplay-export.ts): a small line-based parseFountain (scene heading / action / character / parenthetical / dialogue / transition, tuned to our generator's output) backs exportFdx (Fountain → Final Draft .fdx XML) and exportPrintHtml (Courier + screenplay-margin HTML the browser saves as PDF — no PDF dependency). Scene Studio contract: sendSceneToStudio hands a rendered screenplay scene's Fountain to Scene Studio as its shot-breakdown source — generateSceneBreakdown in scene-studio.ts gained an optional sourceTextOverride so it dramatizes a real, bounded scene (explicit dialogue + action) instead of guessing a beat from raw prose, anchored to the scene's first source_chapter_numbers chapter (one chapter = one Scene Studio project; connective scenes with no source chapter can't be sent). This closes story→script→movie. Routes: GET /projects/:seasonId/{fdx,print}, POST /scenes/:id/to-studio. Steering UI: pick a novella → beat sheet (page-budget meter) → render scenes → Critique per scene → ↻ Apply & re-renderCheck fidelity → export ⬇ .fountain / ⬇ .fdx / 🖨 Print-PDF and 🎬 To Scene Studio per scene. Deferred: golden-adaptation regression tests (need an approved reference screenplay to diff against) + a true server-side PDF.
  • Promo Studio (super-admin, /dashboard/admin/rehearsal-promos): systematizes the promo-video briefs (docs/marketing/promo-videos/) into a UI pipeline. Per shareable rehearsal (run uuid or curated slug), services/rehearsal-promo.ts generates a structured brief (gpt-4o, feature_key rehearsal_room.promo.brief — invents the fear-creature + payoff, beats, 15s cut, per-shot Veo prompts, Midjourney stills prompts, captions) stored in rehearsal_promos (migration 386_rehearsal_promos.sql, keyed by share_key). Then stills (gpt-image-1, 3 images → season-assets/rehearsal-promos/) and admin-uploaded finished video/images (multipart, 200MB cap) with a draft → assets → final → published status. Routes on rehearsal-room.ts: GET /promos, GET/POST(/brief|/stills|/upload|/status) /promos/:key, DELETE /promos/:key.
    • Consistency + asset pipeline (migrations 388 characters jsonb / 389 style_image_url): the breakdown extracts a character bible (canonical descriptions injected into every keyframe prompt) and defaults each character's reference + identity source (character.source_image_url) to its cast portrait (story_cast.portrait_url); keyframes Kontext-condition on those refs (black-forest-labs/flux-kontext-pro) so characters hold their look. Restyle into the scene's lookgenerateCharacterReferences ("Regenerate refs") Kontext-restyles the identity source (cast portrait or upload) into the current style, keeping the face but matching the selected style (falls back to style-forward txt2img when there's no source); source_image_url is preserved so repeated restyles don't drift. Medium from the reference image: because the style_anchor is usually mood/lighting (not a medium) and Kontext can only condition on the face image, describeImageStyle() runs a gpt-4o-mini vision call on style_image_url to extract the actual art style/medium as a prompt phrase. Two-image styling parity: when both a face source and a style image exist, the restyle uses multi-image Kontext (flux-kontext-apps/multi-image-kontext-pro, input_image_1=face / input_image_2=style ref) so it keeps the face and adopts the reference image's real medium — falling back to single-image flux-kontext-pro + the vision-derived medium text if the multi-image call fails. So a photoreal cast portrait actually re-renders painterly/illustrated to match the reference, not just dimmed. (feature_keys scene_studio.style_describe, scene_studio.character_ref.) Without this the cast portraits carried the story's style into every keyframe, overriding the scene's selected style. Then "Regenerate keyframes" flows the restyled refs into every shot. the style_anchor defaults from the season's visual_style and is editable via a curated style preset library + a free-text description box (directly editable); the picker leads with a "★ This show's look" tile sourced from the season's visual_style + poster/chapter-cover (getSceneProject also returns show_visual_style/show_poster_url/chapter_cover_url) when present. The scene style_image_url defaults from chapter_cover_image_url, is replaceable by upload, and conditions characterless shots. Replacement uploads: POST /projects/:id/{style-image,character-reference} (multipart). Per-shot iteration: PATCH /shots/:id (edit prompt) + POST /shots/:id/keyframe (re-roll one shot). Keyframes use Flux 1.1 Pro (engine-selectable, gpt-image fallback) with unique filenames (no stale cache).
    • Image-to-video (Phase D): POST /shots/:id/clip animates a shot's keyframe into a short clip — clipFromImage() calls Replicate Kling (kwaivgi/kling-v1.6-standard, start_image = keyframe, 5s/10s) → scene_shots.clip_url. Assembly prefers clip_url when present (scale/crop to 1920×1080, tpad clone-pad the last frame to the dialogue length, mux the shot's TTS) and falls back to Ken Burns over the still otherwise. Bulk animate is a fire-and-forget background job (clips are 1–3 min each, can't sit in a request): POST /projects/:id/clips returns 202 + kickoffBulkShotClips() (setImmediate, sequential, per-shot isolation, skips shots that already have a clip); progress lives on durable scene_shots.clip_started_at/clip_error columns (migration 390_scene_shot_clip_status.sql) and the frontend polls GET /projects/:id/clips/status (listShotClipStatus → per-shot done|generating|failed|pending|no_keyframe + summary) every 6s. "🎬 Animate all shots" button + a done/total · failed · rendering… chip; per-shot "🎬 Animate" still available. Runtime-untestable locally (needs REPLICATE_API_TOKEN + prod). "▶ Preview clips" — a free client-side "virtual stitch": a modal <video> plays every shot's clip_url back-to-back (key=idx remount so autoPlay fires per clip, onEnded advances + loops), muted/silent, with prev/next + ←/→. No server render — lets you eyeball the cut before paying for the real assemble (which adds dialogue TTS).
    • Editable cut (Phase 4a, migration 395_scene_shot_edit.sql): shots gain trim_start_s/trim_end_s/transition (cut|fade); updateShot accepts them + reorderShots(episodeId, shotIds) (route POST /projects/:id/shots/reorder) sets idx to reorder (reflows the keyframe grid, the preview, and the assembled cut). assembleSceneAnimatic honors trim (head -ss/shortened duration) + fade (dip-through-black via ffmpeg fade filters on each segment; cut keeps the existing concat) + dissolve (cross-dissolve into the next shot). When any non-final shot is dissolve, assembly switches from the concat demuxer to a single xfade/acrossfade filtergraph — segments chain left→right, cross-dissolving (xfade=transition=fade) where the prior shot dissolves and concat-filtering otherwise; running offsets keep overlaps aligned and the dissolve length clamps to never exceed either neighbor. No dissolve anywhere ⇒ the original concat-demuxer path is byte-identical. UI: a per-shot "Cut" row — ↑/↓ reorder, transition select, trim ◀/▶ inputs. Timeline filmstrip (Phase 4b): a horizontal strip of shot keyframe thumbnails above the grid — drag a thumb to reorder (HTML5 DnD → the same shots/reorder route), click to smooth-scroll to that shot card (id="shot-<id>"); shows #/duration + a fade marker. dissolve (cross-dissolve) is shipped — see the Editable-cut entry's transition note.
    • Integrated sound (Phase 6, migration 396_scene_music.sql): scene_projects.music_url holds an optional scene scoregenerateSceneMusic(episodeId) builds an instrumental bed from the project's visual-style mood via Replicate meta/musicgen (budget-capped scene_studio.music, latest-url only), previewable standalone in the UI (🎵 Generate score / 🎵 Re-score + an inline <audio>). When music_url is set, assembleSceneAnimatic ducks the bed under the dialogue — ffmpeg sidechaincompress (music keyed off the voice track) + amix, looped to the cut length (-stream_loop -1, duration=first); the video stream is copied untouched (-c:v copy). No music_url ⇒ assembly is byte-for-byte unchanged; mix failures fall back loudly (service_errors) to the un-scored cut. Route POST /projects/:id/music.
    • Karaoke captions (migration 404_scene_karaoke.sql): when captions_burn is on, captions_karaoke upgrades the burn from static SRT to word-synced karaoke — each word highlights as spoken. Per dialogue shot, assembleSceneAnimatic Whispers the rendered TTS (transcribeWordTimestamps, metered scene_studio.karaoke), realigns to the original line for casing/punctuation, offsets word times by the shot's timeline position, and builds one libass ASS (\k tags) with the font UU-embedded — via the shared karaoke-ass.ts (distilled from the Maxell ad, reusable, no Maxell layout coupling). Falls back to the plain SRT burn if Whisper/ASS yields nothing. First of the Maxell visual options ported into Scene Studio (then branding overlay, cinematic finish). UI: a 🎤 karaoke sub-toggle next to CC.
    • Branding overlay (Maxell option 2/3, migration 405_scene_branding.sql): an optional persistent corner wordmark — brand_text (NULL/empty = off) at brand_position (bottom_right default / bottom_left / top_right / top_left). assembleSceneAnimatic draws it via a final drawtext pass over the assembled cut (white@0.85, shadowed, getFontPath). Per-project per the gift framing (Odessa / Graphene / none). UI: brand text + position in the 🎬 Trailer cards panel. NULL ⇒ unchanged; failure falls back loudly to no-brand. Logo overlay (migration 407_scene_brand_logo.sql): brand_image_url — an uploaded PNG (POST /projects/:id/brand-logouploadSceneBrandLogo) overlaid (scaled to ~⅐ frame width, ffmpeg overlay) at the same corner; takes priority over brand_text. UI: upload/remove logo in the branding row.
    • Cinematic finish (Maxell option 3/3, migration 406_scene_cinematic_finish.sql): cinematic_finish boolean → a final-grade pass eq=contrast=1.06:saturation=1.05,vignette=PI/4,noise=alls=6:allf=t applied before captions/brand (so text stays crisp). Pure ffmpeg, no cost. FALSE ⇒ unchanged; failure falls back to no-grade. UI: a 🎞 film look toggle by CC. Completes the three Maxell visual options ported into Scene Studio (karaoke · branding · finish).
    • Lip-sync (Phase 5, migration 397_scene_lip_sync.sql): per-shot, opt-in. lipSyncShot(shotId) renders the shot's dialogue in the speaker's voice (ttsSegment), then lip-syncs the shot's clip onto it via Replicate (official sync/lipsync-2-pro by default (general video/audio inputs; kling-lip-sync rejected non-Kling clips with E006); overridable via SCENE_LIPSYNC_MODEL; budget-capped scene_studio.lipsync, priced in GEN_PRICE_USD) → scene_shots.lip_sync_url (+ capped lip_sync_versions). lipSyncBuffer resolves the model's latest version (community ids like bytedance/latentsync 404 when run by bare name — they must be version-pinned) and maps the input schema per family (kling-lip-sync: video_url/audio_file; latentsync-style: video/audio). Requires a clip + dialogue (button disabled otherwise). assembleSceneAnimatic prefers lip_sync_url over clip_url (the lip-sync video's own audio is ignored — the rendered TTS is re-mapped so the existing pad/trim/sync logic is identical). Model-path failures surface loudly (service_errors) like #714. Auto-trim: the model animates the whole clip, so lipSyncShot measures the rendered TTS duration (probeDuration) and trims the take to audioDur + LIPSYNC_TAIL_S (default 0.4s, env SCENE_LIPSYNC_TAIL_S) via ffmpeg before saving — cutting the trailing silent mouth movement automatically (the ✂ Split tool still adjusts further). UI: per-shot 🗣 Lip-sync / 🗣 Re-sync button + a 🗣 synced badge that doubles as a toggle — click it to swap the hero player to the lip-synced take with audio (muted={false}, autoplays), so you can actually hear the sync; click again for the silent clip. ✂ Split here (trimShotTakePOST /shots/:shotId/trim {target, end_s}): hard-trims the showing take (clip or lip-sync) to end at the hero's current playhead — discards trailing mouth movement after the line ends — re-encoded via ffmpeg and saved as a new *_versions entry (original restorable). Pure ffmpeg, no gen cost.
    • Staleness / freshness indicators: derived purely from each asset's versions[].created_at (tsOf/shotStale). A shot's clip is stale when its keyframe was regenerated after the clip (keyframeTs > clipTs) → the 🎬 Re-animate button + a hero ⟳ re-animate badge turn amber. Its lip-sync is stale when the clip changed after the take (clipTs > lipTs) → 🗣 Re-sync goes amber. The assembled cut is stale when any shot's used asset (lip-sync > clip > keyframe) is newer than the active animatic_versions entry (assemblyTs): an amber "this cut is out of date — shots #N changed" banner with a Re-assemble button sits above the Animatic, and each stale shot gets an ring in the filmstrip. All client-side, no migration. Route POST /shots/:shotId/lip-sync. Not bulk (most cost/quality-sensitive gen step).
    • Durable in-flight renders (migration 401_scene_render_stamps.sql): single-shot keyframe-regen + lip-sync stamp keyframe_started_at / lip_sync_started_at at the start and clear them on completion (error → keyframe_error / lip_sync_error), mirroring the clip's clip_started_at. The frontend derives renderingShots from these stamps (recent = <8 min), so a page refresh mid-render rehydrates the per-shot "Working…" overlay AND a persistent bottom-left "N renders running" pill (portal'd to body), and polls the project every 5s until the op finishes — including ops started in a prior session. Failed ops show a ⚠ failed badge on the shot.
    • Model currency (Phase 7): the swappable Replicate model ids are env-overridable so keeping them current is a config change, not a deploy — SCENE_CLIP_MODEL (image-to-video, default kwaivgi/kling-v1.6-standard) and SCENE_LIPSYNC_MODEL (default sync/lipsync-2-pro; lipSyncBuffer version-pins community ids + maps input keys per family). New ids should also be priced in GEN_PRICE_USD (unknown ids meter at $0 until added). Aspect ratio (migration 398_scene_aspect_ratio.sql): scene_projects.aspect_ratio (16:9 default | 9:16 vertical | 1:1 square) flows through keyframe gen (ImgAspect, gpt-image size map adds 1024x1536), clip gen (Kling aspect_ratio), and assembleSceneAnimatic (output dims VW×VH derived from it — 16:9 stays byte-identical at 1920×1080, including the Ken Burns scale=2400:-1). UI: a format dropdown beside the keyframe-engine picker; changing it needs a keyframe regen to re-frame. Route: PATCH /projects/:id { aspect_ratio }.
    • Style reference from a character (character card ↳ as style ref): sets the scene's style_image_url to that character's portrait (updateSceneProject accepts style_image_url; PATCH /projects/:id), so the whole scene's look matches a chosen character (e.g. Ethan); shows ✓ style ref when active. (Uploading a style image still works via the ↑ on the first style tile → uploadSceneStyleImage.) After setting it, a confirm offers to also set the show-level style (setSceneShowStylePOST /projects/:id/show-style) — writing the chosen image to story_universes.style_reference_image_url or, for standalone shows, story_seasons.style_reference_image_url (migration 409), which universe-canon-gen's universeStyle prefers — so generated canon characters/places match the look too, not just this scene.
    • Per-character voice (character bible card): each character's dialogue voice resolves per-scene override → story-level (season_character_voices, matched by name) → stable hash (voiceForSpeaker). getSceneProject enriches each character with voice_default (the story voice) so the card shows "Auto · story: <Voice>"; a 🔊 dropdown (bank from GET /api/story-seasons/voice-bank = VOICE_BANK + narrator, each with an ElevenLabs preview_url) with a ▶ sampler (pending selection → ▶ to hear → Apply to commit, so you sample before changing anything) sets a per-scene override stored on the characters jsonb (POST /projects/:id/characters/voice {name, voice_id}, empty clears). assembleSceneAnimatic + lipSyncShot use buildSpeakerVoiceMap/resolveVoice, so the chosen voice drives both the animatic TTS and the lip-sync. No migration (voice lives in the existing jsonb). Voice-change staleness (migration 408_scene_lipsync_voice.sql): lipSyncShot records scene_shots.lip_sync_voice_id (the voice baked into the take); getSceneProject flags lip_sync_voice_stale when it ≠ the character's currently-resolved voice, so the 🗣 Re-sync button + hero badge turn amber (⟳ re-sync · voice) and the shot joins the "cut is out of date" set — nudging a re-sync after a voice change.
    • Two-character fidelity (shotKeyframeBuffer): when a shot has 2+ present characters that each have a reference image, the keyframe composites BOTH via multi-image Kontext (kontextStyleBuffer, input_image_1/_2 = the two refs) so each identity is image-locked — instead of the single-subject path where only the speaker is faithful and everyone else is text-only (a fresh hallucination per shot). Speaker is image #1; 3+ characters image-lock the top two and describe the rest in text. When the shot also has a canon Place, the cast image is then composited INTO the location in a second multi-image pass (characters + place, all real image refs — see Multi-reference composite below). Single-character shots are unchanged. Graceful fallback: a failed multi-image call (logged to service_errors like #714) drops to the place / single-subject / txt2img chain. Higher-fidelity follow-on (per-character masked inpaint to fully eliminate identity blending) is noted but not yet built.
    • Multi-reference composite (characters + place): multi-image Kontext takes 2 images per pass, so shotKeyframeBuffer composites a shot with multiple character refs AND a place ref in two passes — (1) lock the cast together (faces first), upload that as a temp image, then (2) drop the cast into the location (kontextStyleBuffer(castUrl, placeImageUrl, …)). 1 char + place = 1 pass; 2+ chars + place = 2 passes (3+ chars image-lock the top two, rest in text). The place comes from scene_shots.place_keyuniverse_places.image_url (placeImageMap). This is the unified path; the characters-only and place-only branches are the fallbacks beneath it.
    • Camera motion (per shot, migration 399_scene_camera_motion.sql): scene_shots.camera_motion (static/push_in/pull_out/pan_left/pan_right/tilt_up/tilt_down/handheld/orbit; NULL = auto). Drives the image-to-video prompt (cameraPhrase → Kling) and, for still shots, the Ken Burns push direction in assembly (push_in/pull_out/static override the alternating default; NULL keeps it byte-identical). updateShot accepts it; UI is a 🎥 dropdown in the per-shot Cut row. Route PATCH /shots/:shotId { camera_motion }.
    • Trailer cards (migration 402_scene_trailer_cards.sql): optional scene_projects.title_card ({title, subtitle}) + end_card ({headline, sub, cta}). assembleSceneAnimatic renders each as a drawtext card segment (dark bg, centered text, fade in/out, silent audio — renderCardSegment + getFontPath from season-video) and unshifts/pushes it onto segs, so it concatenates/xfades + scores + captions like any shot. NULL cards ⇒ assembly unchanged. This is the framing layer for trailer mode — a gift trailer that opens with the work's title and closes with the Odessa invite. updateSceneProject sets them; UI is a "🎬 Trailer cards" disclosure above the assemble controls. First step of the Amazon-URL→trailer→author-gift pipeline (the production half; ingest + outreach are separate).
    • Chapter meta header (getChapterMetaGET /projects/:id/meta): on selecting a chapter, the detail view shows a poster + show title + genre/setting chips + Premise + a prose excerpt before any breakdown exists (works even when there's no scene project — sourced from resolveChapter + the season poster). Lets you size up the story before committing to a breakdown.
    • Trailer cut (breakdown mode): generateSceneBreakdown(episodeId, { mode: 'trailer' }) swaps the scene system prompt for TRAILER_SYSTEM_PROMPTtrailer grammar (hook → escalate 6–10 short shots → tease/turn, mostly "Narrator" voiceover, never spoils, 1.5–4s shots) drawn from the whole story's mood, not one beat. Auto-seeds the title card (= story title) + a default Odessa-invite end card (only when unset). Route POST /projects/:id/breakdown { mode: 'trailer' }; UI is a "🎞 Trailer cut" button beside Regenerate breakdown (confirms before replacing shots). Everything downstream (keyframes → clips → lip-sync → assemble + cards) is identical.
    • Commercial cut (breakdown mode): generateSceneBreakdown(episodeId, { mode: 'commercial' }) swaps in COMMERCIAL_SYSTEM_PROMPTad grammar (hook ≤2s → 2–4 value beats → CTA, ~15–30s, mostly "Narrator" VO, product/output on screen, 1.5–3s shots) that sells a product/brand rather than dramatizing or teasing a story; it treats the chapter prose as the pitch/ad copy. Leads with the hook shot (no title card) + auto-seeds a CTA-forward end card branded to the show (only when unset). The mode flag is the shared SceneBreakdownMode = 'scene' | 'trailer' | 'commercial'. Route POST /projects/:id/breakdown { mode: 'commercial' }; UI is a "📺 Commercial cut" button beside "🎞 Trailer cut". Everything downstream (keyframes → clips → lip-sync → assemble + cards) is identical. This is the home for the "Al, a Trope" spot (EMBERKILN_AL_MASCOT_KIT.md) and a reusable ad format for any show.
    • Share / preview links (migration 403_scene_share_slug.sql): mintSceneShareSlug mints scene_projects.share_slug (reuses makeShareSlug from rehearsal-room; requires an assembled video_url). Public no-auth route GET /api/scene-studio/shared/:sluggetSharedTrailer returns {title, video_url, poster, title_card, end_card}. Public page /trailer/[slug] plays the cut + the Odessa-invite end card; layout.tsx generateMetadata unfurls title + poster (og:image) + og:video. Admin: a "🔗 share link" button by the Animatic download mints + copies the URL. This is the "send the gift to an author" surface.
    • Captions / subtitles (migration 400_scene_captions.sql): assembleSceneAnimatic builds an SRT deterministically from each shot's dialogue placed on the timeline (buildSrt/srtStamp; dissolve overlaps shift the next cue's start, mirroring the xfade offsets). Always uploaded as a downloadable sidecar (scene_projects.srt_url) when the cut has dialogue; burned into the video via the ffmpeg subtitles filter when captions_burn is set (updateSceneProject toggles it; a libass-less ffmpeg fails loudly to service_errors and ships un-burned + sidecar). NULL/false ⇒ video unchanged. UI: a CC checkbox by the aspect picker + a captions (.srt) download link beside the animatic.
    • Daily spend cap (services/gen-budget.ts): Kling/Flux can rack up a bill fast, so every expensive entry point (keyframes, single + bulk clips, character refs, style presets) calls assertSceneBudget() which sums today's (UTC) scene_studio.* cost from llm_call_log and throws BudgetExceededError (→ HTTP 429) once it hits SCENE_STUDIO_DAILY_CAP_USD (env, default $25, 0 disables). Bulk-animate stops mid-batch (keeping finished clips) rather than failing every remaining shot. GET /api/scene-studio/budget{ cap_usd, spent_usd, remaining_usd } drives a live "Today: $X / $25 cap" meter + progress bar in the assets pipeline (amber under 20% left, red/blocked at 0). Reusable helper for any unattended gen feature: genSpendTodayUsd(prefix) / assertGenBudget(scope, prefix, capUsd). CONVENTIONS: "Meter non-token generation spend → Daily spend cap."
    • Version history (migration 391_scene_versions.sql): re-animating a shot or re-assembling no longer discards the prior take. Clips already upload under timestamped filenames; the animatic now does too (was a fixed -scene.mp4 with upsert — overwrote). Each generated URL is appended to scene_shots.clip_versions / scene_projects.animatic_versions (jsonb [{url, created_at, …}], capped at 12); clip_url/video_url stay the active pointer. Restore an earlier take via the existing PATCH /shots/:id ({ clip_url }) / PATCH /projects/:id ({ video_url }) — both validate the url is a known version. UI: per-shot v1 v2 … take buttons under the clip, and an animatic "Versions" list with make-current + per-version download.
  • LinkedIn outreach generator (growth): services/rehearsal-outreach.ts drafts LinkedIn content for the work vertical — POST /api/rehearsal-room/outreach (kind: 'post'|'dm', useCaseKey/angle/audience/tone/variants) + GET /outreach/options. Broadcast posts are public + IP-throttled (separate rehearsal_rate_limits bucket via namespaced ip_hash, no migration); personalized DMs are super-admin only. Shared UI OutreachGenerator.tsx (variant="public" lightweight on /rehearsal-room/work, variant="admin" full controls at /dashboard/admin/rehearsal-outreach). Honesty posture carries through the prompt (rehearsal, not prediction). feature_key rehearsal_room.outreach.{post,dm}. Matching post image (admin): a per-draft "🖼️ Image" button calls POST /outreach/image (super-admin) → generatePostImage() in rehearsal-og-images.ts (gpt-4o-mini prompt → gpt-image-1 editorial illustration, text-free → season-assets/rehearsal-og/post-*.png) → renders inline with a Download link to attach when posting. No table (storage only).

Organizations & Teams

Hierarchical org → team structure with role-based access (owner / admin / member). Used for shared notebooks and team-scoped visibility.

Linked Accounts (Multi-Account Aggregation)

Allow one user to view content from multiple linked accounts transparently. Recursive — A→B→C resolves all three.

  • Backend: apps/backend/src/routes/linked-accounts.ts
  • SQL helper: apps/backend/src/database/linked-accounts-recursive.sql
  • Migrations: 007_add_linked_accounts.sql, 008_update_linked_accounts_recursive.sql

Gamification & Currency

Drops Currency

Internal currency awarded on signup (50) and monthly (30 free / 250 premium). Spent on tone packs and premium features.

  • Backend: apps/backend/src/routes/drops.ts
  • Migrations: 016_add_drops_currency.sql, 041_add_drop_read_tracking.sql
  • Reference: docs/DROPS_CURRENCY.md

Encouragement Drops

Themed micro-encouragements sent between JQ Bridge users with templates and engagement tracking.

  • Backend: apps/backend/src/routes/encouragement-drops.ts
  • Migration: 027_add_encouragement_drops.sql
  • Reference: docs/ENCOURAGEMENT_DROPS.md, docs/JQ_DAILY_DROPS_CRON.md
  • Cron: apps/frontend/src/app/api/cron/encouragement-drops/
  • Subscriber perk: Graphene+ subscribers get 2× daily allowance (applied at row insert in getUserDrops via PREMIUM_DAILY_ALLOWANCE_MULTIPLIER). Frontend cap-warning is subscriber-aware: non-subs see a "Double your drops" CTA pointing at /dashboard/subscription; subs see only the engagement-bonus hint.

Encouragement Badges

Achievement badges for engagement milestones (first entry, streaks, connection counts).

Tone Packs

User-uploaded collections of tones/moods with associated images. Can be public, private, or sold for drops.

  • Backend: apps/backend/src/routes/tone-packs.ts
  • Migrations: 015_add_tone_packs.sql, 018_create_tone_packs_bucket.sql
  • Reference: docs/TONE_PACKS.md

Goals & Activity

Accountability Goals

Personal goals with check-in milestones and completion tracking.

  • Backend: apps/backend/src/routes/goals.ts
  • Frontend: apps/frontend/src/app/dashboard/goals/
  • Migration: 001_initial_schema.sql (accountability_goals table)
  • Routes: /dashboard/goals, /dashboard/goals/new, /dashboard/goals/[id]

Dream Pro

Aspirational "dream" goals broken into AI-generated steps. Steps can flow into Workout Window, action notes, or step prompts. Unified with Open Energy Experiments via the dreams/clones model — see DreamPro Citizen Science Platform below for the public-facing community layer built on top.

  • Backend: apps/backend/src/routes/dreampro.ts · services/dreampro/
  • Frontend (private dashboard): apps/frontend/src/app/dashboard/dreampro/
  • Frontend (public landing): apps/frontend/src/app/dreampro/page.tsx
  • Cron: apps/frontend/src/app/api/cron/dreampro/process-step-prompts/route.ts
  • Foundational migrations: 029_add_dreampro.sql, 050_dreampro_unification.sql (templates/clones/replications/builds), 051 (legacy bridge), 052_dreams_is_seed.sql, 053_dream_mosaics.sql
  • Reference: docs/DREAMPRO_PLAN.md, docs/DREAMPRO_IMPLEMENTATION.md, docs/DREAMPRO_COMPLETE.md
  • Routes: /dashboard/dreampro, /dashboard/dreampro/[id], /dreampro, /dreampro/templates, /dreampro/templates/[id]

Workout Window

Comprehensive activity-tracking gamification: daily chains, weekly battles, mosaic visualization, AI companion profiles.

  • Backend: apps/backend/src/routes/workout-window.ts · services/workout-window/
  • Frontend: apps/frontend/src/app/dashboard/workout-window/
  • Migrations: 025_add_workout_window.sql, 026_add_ai_users.sql, 030_add_workout_window_ai_images.sql, 031_add_polygon_extraction.sql, 032_add_wireframe_images.sql
  • Crons: generate-chains, battle/generate-weekly
  • Reference: docs/WORKOUT_WINDOW_AI_USERS.md, docs/WORKOUT_WINDOW_CRON_SETUP.md
  • Routes: /dashboard/workout-window, /dashboard/workout-window/chain, /dashboard/workout-window/checkin, /dashboard/workout-window/battle, /dashboard/workout-window/mosaic, /workout-window

Accountability circles + nudges ("Wall Breaker" — Phase 0 loop + Phase 1 VR)

Consented nudge → check-in → coin loop (plan + phases: docs/product/WORKOUT_WINDOW_WALL_BREAKER.md). Consent = joining a circle; you can only nudge someone you share a circle with; when a circle member checks in to their Workout Window, everyone who nudged them today earns +15 Drift coins.

  • Backend: services/accountability.ts (circles CRUD, sendNudge w/ shared-circle + daily-cap checks, creditNudgersForCheckin, getNudgeInbox/markNudgesSeen) · routes/accountability.ts/api/circles (all authenticateUser). Payoff hooked into routes/workout-window.ts right after the check-in insert (mutateBalance reason: 'nudge_reward'); DreamPro clients reuse WW check-ins so they're covered.
  • Frontend: sender surface /dashboard/circles (create/join circles, members + today's status, 👊 Nudge, copy-invite-link, 📊 This week momentum board per circle); receiver = NudgeMarble (rolls into DashboardLayout on an unseen nudge). Nudges also land in the Messages inbox (direct_messages).
  • Phase 2 — per-circle "This week" momentum board (SHIPPED 2026-07-12). A gentle leaderboard on each circle card ranking members over the last 7 UTC days by a momentum score — Workout Window check-ins (×10) weighted over nudges given (×1), so it celebrates showing up first, helping second (calm-not-competitive). Equal momentum shares a rank (competition ranking), ties break on name; own row highlighted. getCircleLeaderboard + pure rankCircleWeek in services/accountability.tsGET /api/circles/:id/leaderboard. No new tables — reads workout_window_checkins + accountability_nudges. Ranker golden-tested (tests/circle-momentum.test.ts).
  • Migrations: 470_accountability_circles.sql (circles/members/nudges; sent_day generated + unique = 1-nudge/nudgee/day cap), 471_accountability_nudge_seen.sql (seen_at). Not a security boundary via RLS — backend-mediated (service role).
  • Phase 1 — VR "Wall Breaker" game (SHIPPED 2026-07-11). WebXR skin over the Phase-0 loop: you stand in a ring of cubicle walls, each a circle member who hasn't checked in; breaking a wall = nudging (POST /api/circles/nudge). Ring layout (no locomotion — turn + break), proximity + point-and-trigger break gesture (clones StoryWalkScene's Marble fire + r3f onClick for controller-ray/AVP-gaze/desktop), name+avatar on each panel, session breaks animate standing→rubble, already-down members (checked-in / already-nudged) render as rubble. In-scene billboarded score + live Drift balance (useDriftWallet, 30s poll); coins are async (credited when the nudgee checks in). Route /dashboard/circles/vr. Discoverability: a direct "Wall Breaker VR" 🥽 nav entry in FeatureMenu (Social group, next to Circles + Workout Window), a 🥽 button on /dashboard/circles, an accountability card on the Workout Window page, and an "Accountability Circles" entry in the Features catalog. Components: WallBreakerRoom (dynamic(ssr:false) loader + circle picker / ?circle=<id>) + WallBreakerScene. Client API wrappers: lib/circles.ts (fetchMyCircles/fetchCircleMembers/nudgeMember). AI-persona top-up (lib/wallBreakerPersonas.ts): real members take the inner ring slots first; the ring is topped up to RING_TARGET (8) with clearly-labelled indigo practice walls so the room's never sparse — persona breaks are cosmetic only (no nudge, no coins; a fabricated nudgee_id would 400 anyway), keeping the coin economy honest. No circle at all → a practice-only room + "start a circle to earn coins" prompt (never a dead end). No new backend/migrations — reads the Phase-0 API. Inherits the fiber-v9/xr-v6 landmines (IMMERSIVE_3D_HANDOFF.md); positions/feel are built-blind first-guesses to tune on-device.
  • Decisions (from the user): opt-in circle · Drift wallet larger reward (15) · in-app + roll-in-marble delivery · conservative caps. Next: Phase 1 — the VR cubicle-matrix skin.

DreamPro Coaching — front-end product (The 30-Day Reset)

The native "challenge in a box" front-end product John promotes — a readable digital guide wrapped around the seeded 30-day-reset DreamPro template, whose chapters deep-link into the live program (/start). Decisions locked 2026-06-14: sequence (challenge-in-a-box now → authored book + EmberKiln audiobook later), both channels (native delivery + ClickBank), lead product 30-Day Reset. See docs/product/DREAMPRO_COACHING_BOOK.md + DREAMPRO_COACHING_CLICKBANK_FUNNEL.md.

  • Content: apps/frontend/src/content/coaching/30-day-reset.ts — the standalone guide prose (intro + 4 weekly chapters + close), wellness-lane disclaimer, RESET_TEMPLATE_SLUG.
  • Product surface: apps/frontend/src/app/30-day-reset/page.tsx — sells + reads in-page; per-week "Start free →" CTAs → /start/30-day-reset?ref=. Free-to-read = native top of funnel. Phase B audiobook: when NEXT_PUBLIC_RESET_AUDIOBOOK_SEASON_ID is set to a produced Graphene story-season, the page embeds the real /seasons/<id>/embed player; teaser until then.
  • ClickBank delivery: apps/frontend/src/app/30-day-reset/welcome/page.tsx — vendor thank-you page; carries the seller's ?ref= (back-end override layer, distinct from ClickBank's HopLink) → account → /start. Per-affiliate attribution: resolves ClickBank's ?affiliate=<nickname> → a coach code via GET /api/coaching/resolve-ref so the back-end override follows the front-end commission. Mapping stored on coaching_referral_codes.clickbank_affiliate (migration 374), set via POST /api/coaching/admin/affiliate-map + the "ClickBank affiliate map" mini-form on /dashboard/coaching.
  • Shared config: apps/frontend/src/lib/coaching.tsDREAMPRO_HOUSE_REF (env NEXT_PUBLIC_DREAMPRO_HOUSE_REF, defaults to seed code SEEDDEMO) + startPlanHref() + RESET_AUDIOBOOK_SEASON_ID.
  • House coach provisioning: enrollClient requires a real (non-seed) coach referral code, so the native funnel needs one in prod. services/coaching-provision.ts provisionHouseCoach() idempotently creates the house channel-partner → coach (active) → coach referral code (all is_seed=false); POST /api/coaching/admin/provision-house-coach (super-admin); one-tap button on /dashboard/coaching returns the code → set NEXT_PUBLIC_DREAMPRO_HOUSE_REF to it.
  • Linked from /coaching (featured-product band). Reuses the shipped /start + enrollClient flow.
  • Per-chapter QR links: POST /api/coaching/admin/links/bulk (super-admin, idempotent by template_slug+label) creates one coaching_links row per item with a distinct utm; the "Generate 30-Day Reset chapter links" button on /dashboard/coaching builds them from RESET_CHAPTERS (full guide + 4 weeks) so each print placement gets its own /go/<id> + QR + scan_count.
  • Routes: /30-day-reset, /30-day-reset/welcome

DreamPro Coaching — "The Time Crunch" (time-boxed workout)

A free, client-side workout generator: pick your minutes (10/20/30/45) + intensity → a deterministic scoping engine assembles the closest-to-full-body bodyweight session that fits (covers movement patterns in priority order: squat·push·hinge·pull·core·locomotion; trims from the low-priority end when time is tight), then a guided interval timer (work/rest, beeps, next-up) runs it. Frontend-only, no backend/LLM in v1.

  • Engine + data: lib/dreampro-workout/generate.ts (generateSession({minutes,level,seed}) → session + flat intervals[]), movements.ts (bodyweight library, pattern/level-tagged, reserves a lottie key per movement).
  • UI: /coaching/workout (picker → plan → guided timer → "log it in Workout Window" CTA); featured band on /coaching.
  • Next seams (documented, not built): an AI pass to personalize cues/swaps; Lottie exercise animations (placeholder reserves the slot); equipment tiers; subscription gating (the "included in each subscription" perk).

DreamPro Coaching — cohort-scoped Workout Window

Manual named cohorts so a coach's clients squad with their cohort, not random strangers (the global Workout Window matches by location). A cohort is a team under one shared "DreamPro Coaching" org; a client is assigned by stamping coaching_memberships.cohort_team_id (+ mirrored into user_teams).

  • Service: coaching-cohorts.ts (createCohort/listCohorts/assignClientToCohort) · Endpoints: GET|POST /api/coaching/admin/cohorts, POST /api/coaching/admin/cohorts/assign (super-admin)
  • Matching: chain-matching.ts matchChains(date, {includeUserIds|excludeUserIds}) + daily-chain-generator.ts runs one pass per cohort + a global pass excluding cohort members (teams balanced per group). No-op (identical to old global behavior) when no cohorts exist.
  • Battles: cohort-scoped too — migration 375 adds workout_window_battles.cohort_team_id (partial uniques: one global + one per cohort/week); battle-system.ts generateAllWeeklyBattles makes a red-vs-blue battle per cohort + a global one; /battle/current + /battle/:weekStart return the viewer's cohort battle (getViewerCohortTeamId).
  • UI: "Super-admin · cohorts" panel + per-seat cohort <select> on /dashboard/coaching. No new tables (reuses orgs/teams/user_teams + the cohort_team_id column from migration 368).
  • Private cohort leaderboard (the second adaptation): getCohortLeaderboard() ranks a cohort by 30-day check-ins (tiebreak program progress); GET /api/coaching/cohorts/:id/leaderboard gated to cohort members / the cohort's coach / super-admin (canViewCohort()). Purpose-built — NOT the participants/citizen-science competition engine. UI: CohortLeaderboard.tsx on /dashboard/my-coaching ("Your cohort", /me returns cohort) + clickable cohort chips on /dashboard/coaching.
  • Cohort-scoped weekly battles (migration 375): workout_window_battles.cohort_team_id (partial uniques: one global + one per cohort/week); battle-system.ts generateAllWeeklyBattles makes a red-vs-blue battle per cohort + a global one; /battle/current + /battle/:weekStart return the viewer's cohort battle (getViewerCohortTeamId).
  • Teammate encouragement clips (DreamPro Glasses Voice Program, Phase 1) — migration 478_cohort_encouragements.sql. A cohort member records a short clip in their OWN voice (not a clone, no synthesis) and broadcasts it to their cohort; any teammate may hear one (random draw over the recent active pool, never the recorder's own). Cohort-scoped UGC — no new voice-consent scope (that gate is only for the deferred coach-CLONE path). Service cohort-encouragements.ts (recordEncouragement uploads the real mime type — NOT forced mp3; getNextTeammateEncouragement, listMyEncouragements, retractEncouragement soft-delete + storage cleanup; NoCohortError). Routes on coaching.ts: POST /api/coaching/encouragement (multer audio, 10MB) · GET /encouragement/next (204 none) · GET /encouragement/mine · DELETE /encouragement/:id. UI CohortEncouragement.tsx on /dashboard/my-coaching (in-cohort only) — MediaRecorder record→preview→share (30s cap), opt-in-to-play <audio controls> teammate player (never autoplay), retract-your-own. Spec: DREAMPRO_GLASSES_VOICE_PROGRAM.md. Phase 1b (built): GET /api/jq/voice/nudge sometimes (TEAMMATE_NUDGE_RATE 0.35) folds in a teammate clip as source:'teammate' — pre-recorded audio_url returned as-is (no TTS/consent gate, works without a voice clone), else falls through to coach → dream. Zero glasses-app change. Caveat: clips are WebM/Opus (browser MediaRecorder); Mentra playAudio WebM support is unverified — may need a server-side MP3 transcode on the teammate path.
  • Shared primitives + tests: getActiveCohortPools() (one cohort-pool resolver for the chain generator AND the battle generator), sumEarnings()/getMyCoachingEarnings() (one money rollup), and workout-window/adherence.ts (pure streak/window math, used by /me + the leaderboard). Unit tests: adherence.test.ts, battle-system.test.ts, coaching-payouts.test.ts. The backend jest suite was non-runnable repo-wide (jest types/ESM-mocking) — fixed via apps/backend/tsconfig.test.json + unstable_mockModule.

DreamPro Coaching — channel partner dashboard

The "how John gets paid" rollup. Network-wide override balance (pending vs paid), coach roster ranked by override produced, client counts, platform take — accrues from the 3-way split recorded on each invoice.payment_succeeded; transfers settle via the coaching_payouts cron.

  • Service: coaching-payouts.ts getPartnerOverview() · Endpoint: GET /api/coaching/admin/partner-overview (super-admin)
  • Frontend: /dashboard/coaching/partner (linked from /dashboard/coaching). v1 super-admin + network-wide; partner-scoped access is a follow-on.
  • Coach payout connection: the payout cron skips coaches without an onboarded Stripe Connect account, so /dashboard/coaching shows a "Get paid" card (all coaches) — Connect status via GET /api/creator/payouts/status, onboarding via POST /api/creator/payouts/onboard-link (reuses the creator-payouts rails), and the coach's own coaching earnings via GET /api/coaching/my-earnings (coach-accessible, no super gate).

Tasks & Calendar

Personal Tasks (user_tasks)

Per-user todo list with categories, priorities, due dates, and Google Calendar sync. Anyone signed in can use it. Tasks are private (RLS-scoped). Quick-add input on the page uses the natural-language endpoint so users can type "Buy groceries tomorrow" and JQ extracts the due date.

  • Backend: apps/backend/src/routes/user-tasks.ts
  • Frontend: apps/frontend/src/app/dashboard/tasks/page.tsx
  • Endpoints: GET /api/user-tasks, POST, PATCH /:id, DELETE /:id, POST /nl-command
  • Migration: 062_user_tasks.sql (and 063_google_calendar.sql adds google_event_id for sync)
  • Auto-sync: when due_date is set, mirrors to the user's Google Calendar (if connected). Updates and deletes propagate. Best-effort, never blocks task ops.
  • Routes: /dashboard/tasks

Product Tasks (product_tasks)

Org-wide product roadmap (super-admin only). The "what we're building next" board. Mirrored at /dashboard/admin/tasks. Claude manages via the /tasks skill.

  • Backend: apps/backend/src/routes/product-tasks.ts
  • Frontend: apps/frontend/src/app/dashboard/admin/tasks/page.tsx
  • Endpoints: GET /api/product-tasks (admin only), POST, PATCH /:id, DELETE /:id, POST /bulk-status, POST /nl-command
  • Migration: 061_product_tasks.sql
  • Skill: .claude/commands/tasks.md
  • Routes: /dashboard/admin/tasks

Product Overview (Rollout board + Feature Index)

The "one spot" super-admin surface at /dashboard/admin/portfolio, two tabs:

  • 🗺️ Rollout boardproduct_portfolio (migration 367): every brand + feature with a status badge (idea → building → beta → live → paid → paused), grouped by brand, editable inline, no deploy. The "where everything stands" view, for talking. (Distinct from Product Tasks = "what's next.")
  • 🧩 Feature Index — the file/route/migration dev catalog from lib/feature-index.ts + docs/ai/INDEX.md. The engineering view.
  • Backend: routes/portfolio.tsGET/POST /api/admin/portfolio, PATCH/DELETE /:id (super-admin) + public GET /api/portfolio (status-filtered to live/beta/paid, no auth).
  • Public brand hub: the public /features page renders BrandHub from the same table (public endpoint) — one source feeds both the admin board and the public hub. Super-admins viewing /features get an inline "Manage in Product Overview" link (Graphene same-page pattern); the admin board cross-links back to /features.
  • Frontend: shell portfolio/page.tsx (auth + tabs via ?tab=board|index) rendering PortfolioBoard + FeatureIndexView. /dashboard/admin/features redirects here (?tab=index).
  • Migration: 367_product_portfolio.sql (seeded; statuses are best-guess, edit live).

Natural-Language Task Commands

Both task systems expose a /nl-command endpoint that uses GPT-4o-mini with JSON response format to parse intent (add/list/update/done/delete) and extract parameters. Used by:

  • The in-app JQ chatbot (detects task intent in messages, routes by role: super-admin → product_tasks, everyone else → user_tasks)
  • The Chrome extension popup's Quick Tasks input
  • The /tasks Claude Code skill

Google Calendar Integration

Per-user OAuth connection to Google Calendar. Tasks with due dates auto-sync as all-day events. Tasks page shows the next 7 days of upcoming calendar events. Read & write calendar.events scope only.

  • Backend service: apps/backend/src/services/google-calendar.ts (raw fetch, no googleapis library)
  • Backend routes: apps/backend/src/routes/google-calendar.ts
  • Frontend panel: apps/frontend/src/components/tasks/GoogleCalendarPanel.tsx (rendered on /dashboard/tasks)
  • Endpoints: GET /connect, GET /callback, GET /status, POST /disconnect, GET /events, POST /events
  • Migration: 063_google_calendar.sql
  • Setup: docs/setup-guides/GOOGLE_CALENDAR_SETUP.md
  • Env vars: GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI, NEXT_PUBLIC_APP_URL

JQ Routines

Background Routines

Per-user background jobs JQ runs on your behalf. Built-in routines auto-seed on first access; custom routines are created from natural-language prompts. Supports manual triggers, "run on chat open if stale >Nh", daily, and weekly cadences. Run history is persisted.

  • Frontend dashboard: apps/frontend/src/app/dashboard/jq-routines/page.tsx — list, toggle, change cadence, Run Now, expandable run history, NL add-routine textbox
  • Backend routes: apps/backend/src/routes/jq-routines.ts
  • Chat integration: apps/frontend/src/components/chat/Chatbot.tsx calls POST /run-if-stale on first open per session; rendered inline via RoutineRunView with one-click "Add all as tasks"
  • Endpoints: GET /, POST /nl-create, POST /:id/run, POST /run-if-stale, POST /runs/:runId/apply, GET /:id/runs, PATCH /:id, DELETE /:id
  • Migration: 064_jq_routines.sql
  • Tables: jq_routines (key, cadence, action_config, enabled, last_run_at), jq_routine_runs (status, summary, result JSONB, items_found)

Built-in: Weekly Review

Runs weekly. Aggregates the last 7 days across journal entries, tasks (created / completed / blocked), spawned routine instances, goals that changed, drops sent and received, and new life-map nodes — then calls GPT-4o-mini for a structured review (summary, wins, open loops, mood trend, routine adherence, questions for next week). Full review + a pre-rendered markdown version stored in jq_routine_runs.result.

  • Aggregator service: apps/backend/src/services/weekly-review.ts — pulls every signal in parallel, formats a compact LLM input, maps GPT output into WeeklyReview shape, also builds the markdown used for "save as entry"
  • Routine executor branch: actionType === 'weekly_review' in apps/backend/src/routes/jq-routines.ts
  • Frontend hook: useWeeklyReviews, useGenerateWeeklyReview, useSaveReviewAsEntry in apps/frontend/src/hooks/useWeeklyReviews.ts
  • Page: apps/frontend/src/app/dashboard/review/page.tsx — latest review pinned at top with stats grid + expanded sections; earlier reviews collapsible below; per-review ⧉ Copy (JSON to clipboard) and 📝 Save as entry (creates a journal entry tagged weekly-review and navigates to it)
  • Navbar link: new journal icon at /dashboard/review for all signed-in users

Built-in: Spawn Recurring Tasks

Runs on chat open if stale >6h. Finds any user_tasks templates (tasks with non-null recurrence JSONB) whose next_due_at is in the past and have status ≠ done, calls spawnInstance to create a regular task from each, and advances the template's next_due_at forward based on its recurrence config.

  • Recurrence helper: apps/backend/src/services/recurrence.ts (computeNextDueAt, describeRecurrence)
  • Spawn logic: spawnInstance in apps/backend/src/routes/user-tasks.ts
  • Recurrence JSONB shape supports daily / weekly (with days_of_week) / monthly (with day_of_month) / weekdays / custom_interval, plus optional time_of_day (morning/afternoon/evening or HH:MM) and start/end dates.
  • Created via nl-command: add #routine to water plants every other day → template with recurrence: { type: 'custom_interval', interval: 2 } and auto-computed next_due_at.
  • Migration: 066_recurring_user_tasks.sql
  • Tasks page endpoint: GET /api/user-tasks/routines (templates with recurrence_summary), POST /api/user-tasks/routines/:id/spawn (do-now), POST /api/user-tasks/routines/spawn-due (batch)

Built-in: Scan Recent Notes

Reads the last 24h of journal_entries, uses GPT-4o-mini to extract task/reminder candidates with category + source_entry_id, surfaces them as a chat card with "➕ Add all as tasks". Never auto-creates tasks — applies only on explicit click via POST /runs/:runId/apply which inserts into user_tasks.

  • Default cadence: on_open_if_stale_1h (opt-out via dashboard toggle)
  • Action config: { lookback_hours: 24, auto_create_tasks: false }

Natural-Language Routine Creation

POST /api/jq-routines/nl-create takes a prose description ("every morning, pull a 3-sentence recap of yesterday's notes") and GPT produces the full routine config — cadence, action_type (scan_recent_notes or custom_prompt), and the LLM system prompt for custom routines.


Life Map

Visual Map of the User's Life

A per-user graph of entities (people, projects, topics, emotions, places, goals, habits, values) extracted from journal entries + tasks. Rendered on /dashboard/life-map through five ViewPoints: Brain Map (force-directed), Spheres (concentric domain rings), Hierarchy of Needs (Maslow pyramid), Timeline (weekly-mention heatmap), and People-First (you-at-center, person orbit). Clicking any node opens a drill-through drawer (see Node Detail below).

  • Frontend page: apps/frontend/src/app/dashboard/life-map/page.tsx — tab switcher + 3 pure-SVG renderers (no heavy graph libs); brain map uses a ~220-iteration Fruchterman-Reingold force sim computed in useMemo
  • TanStack hook: apps/frontend/src/hooks/useLifeMap.tsuseLifeMap + useRebuildLifeMap
  • Backend routes: apps/backend/src/routes/life-map.tsGET /api/life-map (nodes + edges), POST /api/life-map/rebuild (invokes GPT-4o-mini extractor on recent entries + tasks, upserts into life_map_nodes/edges with stable slugs so re-runs merge)
  • Migration: 067_life_map.sql — two RLS-scoped tables: life_map_nodes (slug, node_type, life_domain, life_tier, importance, mention_count) and life_map_edges (source_id, target_id, edge_kind, weight)
  • Built-in JQ Routine rebuild_life_map runs on_open_if_stale_24h — daily refresh without user action
  • Extractor categorizes each node with BOTH life_domain (powers the Spheres viewpoint) and life_tier (powers the Hierarchy viewpoint), so a single extraction feeds every viewpoint

People-First ViewPoint

A "you-at-center" orbit layout focused on the people in the user's life.

  • You sit at the center as a solid indigo disc. Every node_type='person' node orbits around you, placed at a radius inversely proportional to mention_count (strongest relationships sit closer).
  • Placement uses the golden-angle spiral so clumps spread evenly around the full circle.
  • Each person node is sized by mention count, connected to you by a visible spoke whose thickness tracks strength.
  • Under each person, up to 4 small context satellites — derived from edges to non-person nodes — represent what that person is tangled up with (projects, topics, places, feelings). Clicking a satellite opens the drill-through drawer for that context node.
  • Frontend: PeopleView component in apps/frontend/src/app/dashboard/life-map/page.tsx. Pure SVG, no extra backend — uses the shared useLifeMap graph.
  • Empty state shown when no person nodes exist, suggesting the user mention someone by name in an entry or use @name in a task.

Timeline ViewPoint

Heatmap grid of weekly mention counts per node over the last 8-52 weeks (user-selectable). Each row = a node, each column = a week; cell intensity = mention count. Rows grouped by life_domain with domain-colored headers. Click a node label to open the drill-through drawer.

  • Backend: GET /api/life-map/timeline?weeks=N in apps/backend/src/routes/life-map.ts. Loads nodes + recent entries + recent tasks in one shot, then does in-process substring matching against each node's label (plus tag/mention array-contains) and buckets into ISO weeks. O(nodes × sources) — fine for the 10-40 nodes an extractor produces.
  • Hook: useLifeMapTimeline(weeks) in apps/frontend/src/hooks/useLifeMap.ts
  • Renderer: TimelineView in apps/frontend/src/app/dashboard/life-map/page.tsx
  • Rows sorted by total activity (dense rows surface); empty weeks shown as faint squares; non-empty cells tinted by the node's domain color, intensity proportional to count / global_max.

String search finds nodes whose labels literally contain the query. Semantic search finds nodes whose meaning matches the query — so "who do I work out with?" returns your workout partners even if nothing in their node label or slug contains the word "workout".

  • Migration: 069_life_map_embeddings.sql adds embedding TEXT (JSON array of floats), embedding_model TEXT, embedded_at TIMESTAMPTZ, plus a partial index on nodes missing embeddings. JSON storage rather than pgvector keeps the feature portable across Supabase projects that don't have the extension enabled; for 10-40 nodes per user, in-process cosine similarity is trivially fast.
  • Backend: POST /api/life-map/search { query, top_k? } embeds the query via OpenAI text-embedding-3-small, loads the user's embedded nodes, computes cosine similarity in-process, returns { results: [{ node, score }] } ranked. POST /api/life-map/embed-missing walks up to 100 un-embedded nodes and fills them in (idempotent; safe to call repeatedly). Both in apps/backend/src/routes/life-map.ts.
  • The rebuild extractor now embeds every node it touches in a best-effort async step after the upsert (new or re-extracted). Failures are swallowed — rebuilds never fail because embeddings failed.
  • Frontend: useSemanticSearch (mutation) + useEmbedMissingLifeMap in apps/frontend/src/hooks/useLifeMap.ts. embeddingText(n) on the backend combines label · node_type · life_domain · life_tier so single-signal matches work.
  • GraphSearch UI: the input now has a 🔮 button + Enter shortcut. Substring matches still appear live; pressing 🔮/Enter adds a "By meaning" section at the top of the dropdown with similarity badges (0-100%). A small "Embed all" link in the semantic section header backfills missing embeddings.

Pin Nodes on the Brain Map

Drag-and-drop any node on the Brain Map ViewPoint to fix it at that spot. Pinned nodes survive re-layouts: the Fruchterman-Reingold force sim treats them as immovable anchors (they still exert repulsion on neighbors, but they don't move themselves), so adding new nodes or bumping importance shuffles everything else around them. A 📌 badge overlays pinned nodes; the drawer's toolbar shows a 📌 Unpin button when the current node is pinned (unsets pinned_x / pinned_y).

  • Migration: 068_life_map_node_pins.sql adds pinned_x DOUBLE PRECISION and pinned_y DOUBLE PRECISION nullable columns plus a partial index on pinned rows.
  • Backend: PATCH allowlist in apps/backend/src/routes/life-map.ts extended with pinned_x / pinned_y, clamped to the SVG viewBox (0..960, 0..600).
  • Frontend hook: usePinLifeMapNode in apps/frontend/src/hooks/useLifeMap.ts. Optimistic across the shared ['life-map'] cache so drag-to-position feels instant; rolls back on error.
  • Brain Map: seeds a pinned node's position from pinned_x/pinned_y and marks it fixed: true so the per-iteration displacement step skips it. Mouse event handlers translate clientX/Y → SVG viewBox coords, stream dragPos into a local state override, and commit pinned_x / pinned_y on mouseup (only if the cursor actually moved — a plain click without drag still opens the drawer).

Manual Edge Editing

Fix connections the extractor misses (or got wrong). The drawer's Connected section has an × next to every chip to delete that edge, and a + Add connection toggle that opens an inline picker — edge-kind dropdown (related / part_of / works_on / with_person / at_place / feels / causes) + search over nodes not already connected. Selecting a target creates the edge via a POST that upserts on the unique (user_id, source_id, target_id, edge_kind) constraint.

  • Backend: POST /api/life-map/edges, PATCH /api/life-map/edges/:id, DELETE /api/life-map/edges/:id in apps/backend/src/routes/life-map.ts. Ownership checked by looking up both nodes; self-loops and cross-user edges rejected.
  • Frontend hooks: useCreateLifeMapEdge, useUpdateLifeMapEdge, useDeleteLifeMapEdge (optimistic delete across the shared ['life-map'] cache) in apps/frontend/src/hooks/useLifeMap.ts
  • Drawer UI: extends the existing Connected section in apps/frontend/src/app/dashboard/life-map/page.tsx

Map Maintenance (edit / merge / delete / search / back)

Keeps the Life Map maintainable as LLM extraction accumulates duplicates and misses.

  • Backend: PATCH /api/life-map/nodes/:id (rename, node_type, life_domain, life_tier, importance), DELETE /api/life-map/nodes/:id (edges removed via FK cascade), POST /api/life-map/nodes/:id/merge { target_id } — moves every edge from source to target, dedupes collisions on (source_id, target_id, edge_kind) by keeping the heavier weight + later last_seen_at, sums mention_count + takes earliest first_seen_at, then deletes the source. Self-loops after rewrite are dropped.
  • Frontend hooks: useUpdateLifeMapNode, useDeleteLifeMapNode (optimistic across the shared graph cache), useMergeLifeMapNodes in apps/frontend/src/hooks/useLifeMap.ts
  • Drawer UI: ✎ Edit / ⇢ Merge / ✕ Delete buttons on the drawer header. Edit opens an inline form with label, node_type, life_domain, life_tier, importance selectors. Merge opens a searchable picker over all other nodes; confirm dialog explains what happens; after success the drawer navigates to the target node so you're not stranded.
  • Navigation history: drill-through is a stack (most-recent on top). ← Back pops; closing the drawer clears. Merge navigates to the target after success.
  • GraphSearch component in the page header — type-ahead over all node labels/slugs, ranked by mention_count. mousedown-before-blur to let picks fire. Opens the drawer on any pick.

Node Detail Drawer (drill-through)

Click any node in any ViewPoint to open a right-side drawer showing everything we know about that node:

  • Stats: importance, mention_count, total references (entries + tasks)
  • Related journal entries — matched at query time by ilike against title/content; up to 30 most recent
  • Related tasks — matched by title/description ilike AND by array-contains on tags/mentions (uses the slug-tail as a tag token)
  • Connected nodes — resolved via edges in either direction; each is a clickable chip that navigates the drawer to that node (enables drilling through the graph without closing)
  • Mentions-over-time sparkline — weekly buckets from the matching entries
  • Quick action: "➕ Task" input that auto-appends the appropriate sigil (@slug for person nodes, #slug for project/topic nodes) and creates the task via the NL-command pipeline
  • Backend: GET /api/life-map/nodes/:id/context in apps/backend/src/routes/life-map.ts
  • Frontend hook: useLifeMapNodeContext in apps/frontend/src/hooks/useLifeMap.ts
  • Component: NodeDetailDrawer in apps/frontend/src/app/dashboard/life-map/page.tsx

When JQ converts a journal entry into a task (via the scan_recent_notes routine's apply step), the task remembers which entry it came from. This powers three UI affordances:

  1. Task list — any task with source_entry_id shows a small "📝 From note: [title]" link that jumps to the original entry.
  2. Entry detail page — if one or more tasks were spawned from this entry, a 📋 Linked tasks banner appears at the top, listing each task with status/priority/due, each deep-linked to /dashboard/tasks#task-<id>.
  3. Notebook detail page — when an entry has linked tasks, the notebook feed renders the task card in place of the entry card (showing task status + priority + due date) with a "📝 View original note" back-link. The original entry is still reachable via that link.

Data: Migration 070_user_tasks_source_entry.sql promotes the old free-text "From journal entry <id>" description hack into a real source_entry_id UUID REFERENCES journal_entries(id) ON DELETE SET NULL column with a partial index.

Backend:

  • POST /api/jq-routines/runs/:runId/apply now writes the link into source_entry_id (not description).
  • GET /api/user-tasks batches a second lookup to attach source_entry: { id, title } to each task that has one.
  • GET /api/journal and GET /api/journal/:id both attach linked_tasks: [{ id, title, status, priority, due_date }] to each returned entry (batched per list; single lookup on detail).
  • source_entry_id added to the PATCH allowlist so the link can be managed programmatically.

Frontend:

  • LinkedTask type in apps/frontend/src/hooks/useJournalEntries.ts; source_entry / source_entry_id added to the user-tasks types in apps/frontend/src/hooks/useUserTasks.ts.
  • Swap renderer in apps/frontend/src/app/dashboard/notebooks/[id]/page.tsx; banner in apps/frontend/src/app/dashboard/entries/[id]/page.tsx; "from note" chip in apps/frontend/src/app/dashboard/tasks/page.tsx.

Copy Conversation

JQ chat header has a ⧉ button that copies the current conversation (messages + task/routine payloads + timestamps + conversation_id) as JSON to the clipboard, so conversations can be pasted into other tools or referenced later.

  • Implementation: handleCopyConversation in apps/frontend/src/components/chat/Chatbot.tsx

Personal Family

Family members + routines + daily check-ins

Private, parent-owned organiser at /dashboard/family. Distinct from the public DreamPro "family teams" (participants.kind='family') — that's opt-in citizen-science participation; this is the parent's private mirror for their own kids' daily routines.

  • Page: apps/frontend/src/app/dashboard/family/page.tsxwall-shaped overview (redone 2026-07-20). The top is a warm, framed editable wall glance (components/family/FamilyWallPreview.tsx) that mirrors the published /family-wall device screen (dawn palette, live clock/greeting, inline-editable "Tonight's dinner" via PUT /api/family/meals/:date, and a compact members grid whose due-today routines are checkable — reusing the page's toggleCheckin). A compact warm chip bar in the preview links to every panel editor (Meals/Shopping/Notes/Pets/Who's-home/Points/Ideas/Calendar/Kids-chat with its flag badge), replacing the old two-row stack of large nav buttons. Below a "Manage routines & members" divider sit the original detailed member cards (per-member routines grouped by category, inline add-member + add-routine forms, adopt-templates, delete-with-confirm) — the editing depth. Fed by useFamilyToday (no extra blocking fetch; the meal is a best-effort side fetch). Empty-state guides first-time setup.
  • Backend: apps/backend/src/routes/family.ts — full CRUD on members + routines + check-ins, plus a GET /api/family/today?date=YYYY-MM-DD rollup that returns members + their routines + today's check-ins in one shot to avoid N+1.
  • Endpoints: GET/POST /api/family/members, PATCH/DELETE /api/family/members/:id, GET/POST /api/family/members/:memberId/routines, PATCH/DELETE /api/family/routines/:id, POST/DELETE /api/family/routines/:id/checkin, GET /api/family/today.
  • "Add me" (self-member): POST /api/family/members/self (auth, idempotent) adds the OWNER as a family_members row linked to their own account (linked_user_id = parent_user_id, relationship parent) so they appear in the member grid + on the wall and can track their own routines/reminders. is_self (= linked_user_id === parent_user_id) is computed on both the members list and the getFamilyTodayRollup members; the /dashboard/family card shows a You badge (and suppresses the generic "Linked" badge), and the "🧑 Add me" button hides once a self-member exists. No migration (reuses migration 075's linked_user_id).
  • Migration: 075_family_members_and_routines.sql — three RLS-scoped tables. family_members (parent_user_id, display_name, relationship, birthdate, avatar_emoji, notes, linked_user_id for the future kid-account flow). family_routines (cadence: daily/weekdays/weekly/custom, days_of_week TEXT[], time_of_day, category). family_routine_checkins (UNIQUE on routine_id+checkin_date so re-toggling overwrites; checked_in_by tracks who marked it).
  • Migration: 076_family_routines_extended_cadence.sql — extends the cadence enum to include monthly / quarterly / annually / as_needed so the catalog can cover annual physicals, dental checkups, vaccinations, etc. Application layer uses the most-recent 'done' check-in to decide due-today for these (no DB next_due_at column).
  • Adopt-a-Routine catalog at apps/backend/src/routes/family-routine-templates.ts — ~35 curated templates tagged with age bands (0-2, 3-5, 6-12, 13-17, 18+). Endpoints: GET /api/family/routine-templates?age_band=..., GET /api/family/members/:id/routine-templates (auto-derives age band from birthdate, surfaces which templates the member already adopted), POST /api/family/members/:id/adopt-routines (body: { template_slugs: [...] }, idempotent — skips by title match). Frontend Adopt panel groups templates by cadence, pre-checks recommended ones, dims already-adopted entries.
  • Family-account linkage (built — migration 452): closes migration 075's deferred kid-account flow. jq_bridge_connections.family_member_id (migration 452, nullable FK, ON DELETE SET NULL) lets a family-type invite carry the family_members row it links to. Parent flow: on /dashboard/jq-bridge → Add Connection → type Family, an optional "Link to a family member" picker (unlinked members from GET /api/family/members) sets family_member_id on POST /connections (validated: family-type only + member owned by the caller). Accept flow: POST /api/jq-bridge/accept now runs under maybeAuthenticateUser (soft-auth — a signed-in acceptor gives us their user id without blocking anonymous accepts), and on success sets family_members.linked_user_id to the acceptor. The go/no-go guard is the pure, golden-tested resolveFamilyLink() (services/family-link.ts, test): links only a family invite with a member ref, an authenticated non-parent acceptor, and an unlinked member — never overwrites an existing link (a re-used token can't hijack a member), plus a .is('linked_user_id', null) race guard on the UPDATE. Generic by design — links ANY family member (spouse, adult child, sibling), so no minor-specific policy lives in the code. Once linked, migration 075's RLS lets that member see + check in only their own routines (siblings invisible). Discovery from where parents actually are: each unlinked member on /dashboard/family has a "🔗 Invite to link account" footer action (inline email → POST /api/jq-bridge/connections with connection_type: 'family' + family_member_id), and a linked member shows a "Linked" badge + "✓ Account linked".
  • "Suggest a set of routines" — the Adopt panel now leads the member-card CTAs ("✨ Suggest a set" before "➕ Add one manually"), the empty state offers it inline, and it has an explicit age/situation picker (?age_band= override on GET /members/:id/routine-templates, so a parent can propose "routines for a 12-year-old" with no birthdate on file).
  • JQ Day Planner (migration 585) — a "🗓 Plan the day" button per member proposes an ordered day from that person's de-identified context (age band, routines due today, family calendar, sleep/wake, growth goals, weekend fetchEvents). Service family-day-planner.ts (gatherPlannerContext strips name/birthdate; generateDayPlan → structured JSON blocks via complete gpt-4o-mini, feature_key: family.day_planner). Table family_day_plans (one row/member/day: blocks jsonb {id,title,time_label,kind}, dropped jsonb = deselected titles kept as a goal-nudge signal). Endpoints: POST/GET/PUT /api/family/members/:id/day-plan[/generate]. Frontend DayPlannerPanel — generate → drag-reorder → ✕-deselect → "Keep this plan". Spec + the follow-on goal-formation nudge: FAMILY_DAY_PLANNER.md.
  • Upkeep freshnessupkeep-freshness.ts (pure, golden-tested computeFreshness: last-done + interval → green/yellow/red + %fresh). Wired two places: (a) routine freshness dotsgetFamilyTodayRollup attaches freshness per routine (cadence→interval, last check-in→last_done, 400-day bounded fetch), rendered as a dot in RoutineRow; (b) household upkeep tracker (migration 586, family-upkeep.ts, /dashboard/family/upkeep) — family_maintenance_items (interval_n/interval_unit, due_soon_frac, reorder_url, last_done_at, room_id reserved) for furnace filter/dryer vent/bathroom clean; GET/POST/PATCH/DELETE /api/family/upkeep + POST /:id/done, list sorted most-overdue-first, starter catalog. Spec: UPKEEP_FRESHNESS.md.
  • House Map (migration 588, family-rooms.ts, /dashboard/family/house) — family_rooms (name, kind, pos_x/y/w/h normalized) laid out as draggable + resizable tiles (the AisleAsk store-map primitive reused). Household upkeep hotspots pin to rooms via family_maintenance_items.room_id (FK added in 588, ON DELETE SET NULL). getHouseView returns { rooms: [...each with items + worst-of freshness color + needs_attention], unassigned }. GET /api/family/house + POST/PATCH/DELETE /api/family/rooms; assign an item to a room via PATCH /api/family/upkeep/:id { room_id }. Spec: FAMILY_HOUSE_MAP.md.
  • Navbar: new 👥 Family icon between Notebooks and Stream, visible to all signed-in users.
  • Routes: /dashboard/family, /dashboard/family/upkeep, /dashboard/family/house

Cornerstone — the family's own governed home-AI (public page + waitlist + parent console)

The governance console + age-graded-visibility design law OVER the existing kid-chat (not a rebuild — family_kid_chat already holds per-child policy + a Sophia-style local safety classifier + the audit). Built 2026-07-30, live for own-family use; opening it to other families' kids is COPPA/counsel-gated (own-family use isn't — the owner is the parent). Design-law core cornerstone-oversight-core.ts (pure, golden-tested test): oversightLevelForAge (3-5→full, 6-12→summary, 13-17→flags_only — visibility decays toward autonomy) + buildOversightView (transcript exposed ONLY at 'full'; flagged safety concerns surfaced at EVERY level — privacy applies to ordinary chat, never to safety). Service cornerstone-oversight.ts getFamilyOversight composes kid-chat data through it; GET /api/cornerstone/overview (authed parent); console /dashboard/cornerstone (age-band control writes via the existing PATCH /api/family/kids/:id/chat). See FAMILY_AI_PROVISIONING.md.

Cornerstone waitlist — the family's own governed home-AI (public page + waitlist)

The consumer product name for the Family AI Provisioning concept ("Bedrock for families" — parental visibility + age-graded policy + real data ownership over kids' AI; loving oversight, not surveillance; local-hardware endgame via a Cicero license, never a partnership). Only the validation surface is built: public page /cornerstone (server, scoped OG, painterly hero public/painterly/cornerstone-founded-home.webp) + client CornerstoneWaitlistPOST /api/cornerstone/interest (route, cornerstone_interest migration 602: email/name/message/tier (software|hardware)/source). The waitlist is the deliberate first build — it validates willingness-to-pay + which tier to sequence before any hardware spend. Admin review: GET /api/cornerstone/admin/interest (super-admin, returns signups + byTier counts). Surfaced in Navbar "More", SiteFooter, /features. Optional Slack ping via CORNERSTONE_NOTIFY_SLACK. Apply migration 602.

Social outbox — compose + send-later social posts (migration 603)

A general-purpose social post queue (distinct from social_post_log #343, which is per-(season,video)). Compose a single post or a thread, hold it, then send it. Publishing is human-gated by default: the manual Send now button in /dashboard/admin/social-outbox. Table social_outbox (migration 603): title, channel (CHECK bluesky, extensible), posts jsonb (string[] — one per post in a thread), link (appended to the last post), status (draft/scheduled/sending/sent/failed), scheduled_for, result_url, error. Service social-outbox.ts: CRUD + sendOutboxPost (chains the thread via bskyPost's reply target — post 1 = root, each next replies to the previous; appends link; stores the first post's web URL via atUriToWebUrl; on mid-thread failure → failed noting how far it got) + sendDueScheduledPosts (the cron worker). Routes /api/admin/social-outbox (super-admin) social-outbox.ts. Opt-in cron social_outbox (cron-toggles.ts, OFF by default, heartbeat social_outbox_tick, 15m) sends scheduled_for-past rows — so scheduled auto-publish is explicit opt-in, never the default. Seeded with the Cornerstone launch copy. Apply migration 603.

Family Wall — full-screen display on an old iPad / wall TV (migration 518)

The display half of the family photo-frame V1 (see FAMILY_PHOTO_FRAME.md), shipped without the counsel-gated photo layer. A parent mints a "wall" — an unguessable, revocable token — and opens /family/wall/<token> full-screen on a spare device; it renders members + today's routines (name + emoji + done/pending) read-only, no app, no hardware. This is the "named frame → scoped feed" primitive the photo-frame reuses once counsel clears photos.

  • Migration 518_family_wall_displays.sqlfamily_wall_displays (parent_user_id, unguessable token, name, show_routines, theme ∈ dawn/dusk/forest/mono, revoked_at, last_seen_at). RLS-enabled with no anon policies — all access via the backend service role (token-table posture); the token is the capability.
  • Shared rollup service apps/backend/src/services/family-today.tsgetFamilyTodayRollup(parentUserId, date) + the pure isRoutineDueOnDate were extracted from the /today route so the wall serves the exact same computed shape (single source of truth; /today is now a thin wrapper).
  • Pure display projection apps/backend/src/services/family-wall-core.tsprojectWallView is deliberately lossy: strips birthdate/notes/ids/parent_user_id/linked_user_id, keeps only name + emoji + today's routines. It's served to an unauthenticated token holder, so the strip is the privacy contract. Golden-tested (10 cases) in tests/family-wall-core.test.ts — including a leak guard asserting no PII key survives the projection.
  • Endpoints (in routes/family.ts): GET /api/family/wall (list, auth), POST /api/family/wall (create, auth), PATCH /api/family/wall/:id (rename/theme/show_routines/allow_checkin/revoke, auth), DELETE /api/family/wall/:id (auth), GET /api/family/wall/view/:token — PUBLIC (token-scoped, Cache-Control: no-store, stamps last_seen_at; returns { view, theme } or 404 when unknown/revoked), and POST /api/family/wall/view/:token/checkin — PUBLIC (opt-in tap-to-check-off, see below). Both public routes in the route-auth audit's INTENTIONAL_PUBLIC (the token IS the auth).
  • Tap-to-check-off (migration 519_family_wall_checkin.sqlallow_checkin boolean default false): turns the read-only wall into the interactive chore chart (the actual daily-use feature of Skylight/Hearth). Off by default, opt-in per wall. Even when on, checkinFromWall re-scopes the routine to the wall's own parent_user_id server-side (via family_members.parent_user_id) — a leaked token can at most toggle that family's own routines for today, idempotently; attribution goes to the parent (checked_in_by). The projection exposes a routine id only when allow_checkin is on (the write handle) — a read-only wall never even emits ids (asserted by the leak-guard test). The display page makes rows tappable + optimistic when enabled; the management UI has a per-wall toggle.
  • Alive touches (migration 520_family_wall_message.sql): a per-wall parent message banner (message ≤280 — "Grandma visits at 5!", edited inline, 📌 banner), an all-done 🎉 celebration when every due routine is checked, a time-of-day greeting, and a check-off chime (WebAudio two-note ding on mark-done).
  • Ambient / screensaver mode (migration 522_family_wall_ambient.sqlscreensaver ∈ off/dim_clock/fireplace/aurora, idle_after_seconds 30–3600, ambient_audio ∈ off/fire/rain/waves/brown): a 24/7 wall fades to a calm ambient screen after inactivity, waking on any tap. All asset-free — visuals are CSS (dim clock / animated ember fireplace / aurora drift) and the soundscapes are synthesized in-browser via WebAudio (startAmbient — brown-noise base + filters; fire adds random crackle bursts, waves an LFO swell), so there are no media files to host and it works offline. Audio needs a gesture (browser rule) → the first tap enables the configured track + a sound toggle. Clamps are pure/tested (clampScreensaver/clampAmbient/clampIdleSeconds). Managed per wall on /dashboard/family/wall. Extensions that plug into this same idle framework: an owner-supplied fireplace video URL, and a family-photo Ken Burns collage — the latter is the counsel-gated photo layer (FAMILY_PHOTO_FRAME.md); the photos just become a screensaver source once counsel clears them (not built).
  • Per-member streaks (no migration — computed): a 🔥 consecutive-day "showed up" streak per member, from the last ~45 days of done check-ins. computeStreak (pure, golden-tested) counts consecutive days ending today (or yesterday if today isn't done yet, so it stays "alive"); computeMemberStreaks does one grouped query in resolveWallView and passes streakByMemberId into the projection. Shown as a 🔥N badge on the member card when ≥2.
  • Message in the parent's own voice (migration 521_family_wall_message_voice.sqlspeak_message + message_audio_url): the message banner can be spoken in the parent's cloned voice via sayInUserVoice (the jq-voice pipeline, featureKey: 'family.wall_message', cache-first). Rendered eagerly on save (applyMessageAudio in family-wall.ts) so the public poll path never touches the voice service — it serves a stored MP3 URL. Degrades silently to text when the parent has no clone / hasn't consented (NoVoiceError/NeedsConsentErrormessage_audio_url null + a voice_warning surfaced to the management UI). The wall shows a 🔊 Play button + best-effort autoplay when a new spoken message arrives. Parent's own voice → not counsel-gated (they're the subject + controller; existing voice-clone consent applies), unlike the minors'-photo layer.
  • Frontend: app/family/wall/[token]/page.tsx — client full-screen display (live clock, 60s poll, Wake Lock API to keep the screen on, 4 themes, noindex, tap-to-fullscreen, graceful off/error states; uses useParams() not the server params-Promise). Management UI app/dashboard/family/wall/page.tsx — create/copy-link/open/theme/toggle-routines/turn-off/delete + a "live now / Nm ago" liveness badge from last_seen_at. Linked from /dashboard/family header.
  • Owner in-place admin (no migration): when the public wall is opened by someone signed in as its owner (their own laptop/phone — NOT the mounted display, which isn't logged in), the page surfaces hover "edit" deep-links straight into the dashboard: a per-member ✏️ Edit pill + an avatar hover overlay linking to /dashboard/family?member=<id>, and a 🛠 Manage wall menu (expands from the gear) of deep-links to every settings surface (members, wall, upkeep, rooms, shopping, calendar, pets, who's-home, alarms, intercom, screen time). Detected via GET /api/family/wall/view/:token/admin (authed; returns { is_owner, members:[{id,display_name}] } only when req.user.id === display.parent_user_id, else { is_owner:false }). /dashboard/family reads ?member=<id> to auto-open + scroll to that member's editor. Everything is owner-only + additive; the read-only display path is unchanged.
  • Public product page app/family-wall/page.tsx surfaces the full ability set + two large image-led HighlightBand sections (Bedtime / sleep stories, Off the couch) whose painterly images are generated offline by scripts/generate-painterly-images.mjs (see CONVENTIONS "Painterly marketing-section images"). SEO cluster: /compare/family-wall (lib/family-wall-compare.ts).
  • Routes: /family/wall/[token] (public), /dashboard/family/wall (owner).

Kids' JQ chat + the AI-safety layer (migration 536)

A child gets their own friendly JQ chat screen (/family/kid/<token>), wrapped in a safety layer that's the whole point — the base for a "safe family AI" (where [[project_ar_glasses_living_voice]]'s Sophia local NLU later slots in as the decide-locally pre-filter). Off by default; a parent enables it per child at /dashboard/family/kids.

  • The guard (family-kid-chat.ts kidChatTurn), in order: config enabled + un-revoked → per-child daily cap + rate limit → local safety pre-filter (the Sophia stand-in, family-kid-safety-core.ts localSafetyVerdict — runs BEFORE the cloud so the worst content never leaves the server + a net if cloud moderation is down; conservative: blocks only unambiguous high-harm, and DELIBERATELY does NOT block distress/emotional content — those must reach the caring prompt, locked by tests; sophiaSafety SOPHIA_URL seam for real cicero.sh NLU) → moderate the child's message (OpenAI omni-moderation-latest; if flagged, the LLM is never called — a safe deflection is returned) → LLM (gpt-4o-mini, family.kid_chat) with the age-banded safety system prompt (pure family-kid-chat-core.ts kidSystemPrompt — non-negotiable refusals: violence/sexual/self-harm/drugs/hate/scary, no personal-info, no secrets-from-parents, steer to a trusted adult; + parent's blocked topics) → moderate JQ's output (flagged → replaced) → every turn logged to family_kid_chat_messages for the parent. Golden-tested (the prompt's refusals are locked, 7 cases).
  • Migration 536: family_kid_chat (per-child token/enabled/age_band/blocked_topics/daily_cap/revoked) + family_kid_chat_messages (parent-visible log w/ flagged/flags).
  • Parent routes (auth → resolveFamilyOwnerId): GET /api/family/kids, POST/PATCH /api/family/kids/:memberId/chat, GET /api/family/kids/:memberId/messages. Public (token, disabled-by-default + guarded): GET /api/family/kid-chat/:token, POST /api/family/kid-chat/:token/say (allowlisted in the route-auth audit).
  • Parent oversight UI shows enable/age/blocked-topics/daily-cap + the full conversation log (flagged turns highlighted). Kid UI = a friendly JQ orb + chat bubbles + mic.
  • Flag alerts (safety loop, migration 537): a flagged turn must reach the parent, not just sit in the log. family_kid_chat_messages.reviewed + getFlagSummary/markFlagsReviewed; GET /api/family/kids/flags, POST /api/family/kids/flags/reviewed. Surfaced as a red ⚑ badge on the /dashboard/family "Kids' chat" link and a review banner atop /dashboard/family/kids.

Ask JQ on the Family Wall — "JQ mode" home assistant (migration 535)

JQ becomes the family's home assistant on the wall: ask a question (voice via Web Speech API, or type) and JQ answers grounded ONLY in the wall's current content (routines/meals/shopping/pets/presence/calendar/weather/notes), spoken back via the browser. Presented as "JQ mode" — a full-screen JARVIS/Her-style animated orb (asset-free CSS; idle/listening/thinking/speaking states). Opt-in per wall (allow_ask, default off) since it costs LLM calls + the endpoint is public.

  • Migration 535 family_wall_displays.allow_ask.
  • Service family-wall-ask.ts: askWall(token, question, date) — checks allow_ask + un-revoked, rate-limits (≤8/min/wall), calls resolveWallView for the grounding context (so JQ answers about exactly what the wall shows — a panel that's off → "not on the wall"), and complete() (gpt-4o-mini, family.wall_ask) with a strict "answer ONLY from the wall data, never invent" system prompt.
  • Public POST /api/family/wall/view/:token/ask (allowlisted in the route-auth audit; token-scoped, rate-limited). Wall UI = "🔮 Ask JQ" button → the full-screen orb; management allow_ask toggle.
  • Decide-locally pre-filter (the Sophia stand-in) — BUILT. askWall now runs a local intent pre-filter first (family-wall-prefilter.ts: classifyIntent + answerFromIntent, pure + golden-tested): common questions ("what's for dinner?", "who's home?", "have the pets been fed?", weather/calendar/shopping/points/routines) are answered deterministically from the wall data — NO cloud LLM call, instant, private; only genuinely open questions fall through to the LLM (source: 'local' | 'ai', shown as "⚡ answered from the wall"). Sophia adapter seam: sophiaClassify routes intent classification to a local cicero.sh NLU when SOPHIA_URL is set (1.5s timeout, fail-safe to the built-in classifier) — this is where the real [[project_ar_glasses_living_voice]] Sophia plugs in as a better classifier; the deterministic answerers stay the same.
  • Safety direction (still future): kids'-facing open AI chat rides on the moderation/gating layer above; extending the local pre-filter to it (local safety pre-classification via real Sophia NLU, not keywords) is the next "safe family AI" step.

On-wall editing + background image (migration 538)

Two per-wall settings on family_wall_displays: allow_editing (boolean, default off) and background_image_url (text, ≤1000, http(s) only).

  • Add-from-wall (allow_editing, a SEPARATE opt-in from allow_checkin — check-in taps existing items; editing creates content): three public token routes gated on allow_editing, each re-scoping the write to the wall's own parent — POST /api/family/wall/view/:token/shopping-add (shoppingAddFromWalladdShoppingItem), /meal-set (mealSetFromWallsetMeal, blank title clears), /note-add (noteAddFromWalladdNote). Service helpers in family-wall.ts via a shared resolveEditableWall(token) guard; all three in the route-auth INTENTIONAL_PUBLIC set. The wall UI surfaces quick-add buttons + tap-to-add ghost outlines only while the pointer is moving (hintsVisible, fades after ~4s) so an unattended wall stays clean; a compact inline editor modal posts them.
  • Background image: owner picks from their own entry-backgrounds journal art (NOT the counsel-gated family-photo layer) — GET /api/family/wall/backgrounds (auth, listMyWallBackgrounds → distinct journal_entries.background_image → public URLs). Stored as a full URL; wall renders it full-bleed behind a theme-aware scrim. sanitizeBackgroundUrl (pure, golden-tested) rejects non-http(s)/relative/javascript:/data:/over-long. Settings picker + "Add-from-wall" toggle on /dashboard/family/wall; a ⚙️ Settings link reveals on the wall with pointer activity.

JQ voice actions + device identity + reminders (migration 539)

"Ask JQ" can now act, not just answer (design: FAMILY_WALL_JQ_ACTIONS.md). Requires allow_ask AND allow_editing.

  • Wall device identity: family_wall_displays.location_kind (shared|personal, default shared) + owner_member_id (validated to belong to the parent). Set in wall settings ("Screen: Shared/Personal" + owner picker). clampLocationKind (pure).
  • Action engine (pure, golden-tested family-wall-actions.ts): detectWallAction maps an utterance → {shopping_add|meal_set|note_add|reminder_add} deterministically (no LLM); looksLikeAction gates a parseActionLLM fallback (gpt-4o-mini, strict JSON) for fuzzier phrasing; coerceAction trust-boundaries the client-echoed action on /act. askWall tries an action first when the wall allows editing, else falls through to the existing Q&A.
  • Attribution rule (actOnWall): a reminder's assignee = the member picked in the ask-who step → a name matched in the utterance → a personal wall's owner_member_id; on a shared wall with no match, JQ returns needs_who + the member list and the wall shows member buttons (chooseWhoPOST /api/family/wall/view/:token/act with member_id). Shopping/dinner/note never prompt.
  • Reminders: family_reminders (one-off, member-assigned — NOT a recurring family_routine; due_at column reserved, unused in v1). Service family-reminders.ts (addReminder re-scopes the member to the owner, getRemindersByMember, completeReminder). Shown on the assignee's wall member card (📌); reminder id exposed only on an allow_checkin wall (tap-to-complete → POST /api/family/wall/view/:token/reminder-check, reminderCompleteFromWall). Both new public routes in the route-auth INTENTIONAL_PUBLIC set.

Family Wall intercom (migration 540)

Tap-to-talk to a specific wall screen (design: FAMILY_WALL_INTERCOM.md). Its own per-wall opt-in allow_intercom (default off), separate from checkin/editing.

  • Migration 540: family_wall_displays.allow_intercom + quiet_start/quiet_end (local minutes-from-midnight, nullable) + family_intercom_messages (to_wall_id, to_member_id?, from_label, body, audio_url?, delivered_at/played_at).
  • Quiet hours (pure, golden-tested family-intercom-core.ts isQuietNow — handles overnight wrap; isOnline; hhmm⇄minutes): during a screen's window an intercom message shows silently (no auto-speak) — enforced on the wall; a hard rule for bedroom/minor screens. Frontend mirror lib/quiet-hours.ts.
  • Service family-intercom.ts: listScreens (online via last_seen_at + shared/personal + receivable members — personal→owner, shared→home members via family_presence), sendIntercom (owner-authed, validates wall+member belong to the parent + allow_intercom), getWallInbox (wall polls by token → unplayed messages + quiet window, best-effort delivered stamp), ackIntercom (mark played).
  • Endpoints: GET /api/family/intercom/screens + POST /api/family/intercom/send (auth); public GET /api/family/wall/view/:token/inbox + POST /api/family/wall/view/:token/intercom-ack (token-scoped, in INTENTIONAL_PUBLIC). Dashboard /dashboard/family/intercom (screens + type-to-talk); the wall polls its inbox on a 30s tick → banner + speechSynthesis (muted in quiet hours) → ack. allow_intercom + quiet-hours controls on the wall settings page. v1 = typed messages; audio-clip recording (audio_url column reserved) is a follow-up.

Family Wall sleep stories (migration 542, Plus)

A calm 🌙 bedtime mode on the wall (design: FAMILY_WALL_SLEEP_STORIES.md). Big reuse — no new content/TTS: getSleepStoriesForWall() in family-wall.ts surfaces the existing narrated catalog (sleep-story-gen.tslistRecentSleepStories() = story_seasons universe='sleep_story') + one story_episodes.chapter_audio_url per season. Per-wall opt-in show_sleep_stories (migration 542); enabling it is Plus-gated (requireFamilyWallPlus in updateWallDisplay). resolveWallView attaches sleep_stories (id/title/poster/audio) only when on. The wall shows a "🌙 Sleep stories" launcher → a full-screen shelf → a calm player (poster + play/pause + sleep timer 15/30/45 min that fades out + closes). Settings toggle (✨Plus badge) in the Interaction section.

Family Wall voice alarm clocks (migration 541, Plus)

The wall wakes the family (design: FAMILY_WALL_MONETIZATION.md Plus bundle). A child wakes to a parent's voice; a parent to a motivational quote or their own cheer. family_alarms (per member: minute, days_of_week 0-6, kind parent_voice|quote|chime, message, audio_url, enabled). Service family-alarms.ts: CRUD + getAlarmsForMembers(owner, ids, dayOfWeek); a parent_voice alarm's message is pre-rendered in the owner's cloned voice on save via sayInUserVoice (cached; falls back to the browser voice if no clone). Gated behind Family Wall Plus (requireFamilyWallPlus on create + enable). Endpoints: GET/POST /api/family/alarms, PATCH/DELETE /api/family/alarms/:id (auth → 402 when Plus enforced). resolveWallView attaches today's active alarms per member (weekday anchored noon-UTC for TZ stability); the wall page fires them at minute (once/day guard) — clone-voice clip / spoken quote / wake tone — with a full-screen wake banner. Manage at /dashboard/family/alarms.

Family Wall Plus — the freemium gate (no migration; enforcement off)

Entitlement gate for monetization (design: FAMILY_WALL_MONETIZATION.md). Reuses the shared subscriptions table (plan_key='family_wall_plus') — same pattern as write-cafe-pro; no new table. Service family-wall-plus.ts: isFamilyWallPlus(userId) (active, keeps access through a cancelled-but-not-ended period), requireFamilyWallPlus(userId, feature), familyPlusStatus. Master switch FAMILY_WALL_PLUS_ENFORCED (default off)requireFamilyWallPlus is a no-op until flipped, so the gate shipped without paywalling anything. Enforced at the service layer (public token routes gate on the wall's OWNER): createWallDisplay (2nd+ wall), askWall + actOnWall (Ask JQ), sendIntercom, updateWallDisplay (show_sleep_stories), alarm create/enable, kids'-chat enablement (updateKidChat when enabled:true) → HTTP 402 {error, upgrade, feature}. Cloned-voice wall messages (applyMessageAudiosayInUserVoice) gate gracefully — a non-Plus owner's message still shows and reads aloud in free browser TTS, but the paid clone render is skipped with a plus_required voice_warning (no 402, no ElevenLabs spend). GET /api/family/wall/plus-status drives the settings upsell banner + ✨Plus badges (only when enforced && !plus).

Stripe checkout — BUILT (mirrors Graphene+/write-cafe-pro): POST /api/payment/family-wall-plus/checkout ({ interval: 'monthly'|'annual' } → Stripe Checkout URL, metadata.plan_key='family_wall_plus'); GET /api/payment/family-wall-plus/config ({ available, annualAvailable } — false until the price envs are set, so the CTA hides a button that would 503). The webhook's priceToPlanKey maps STRIPE_PRICE_FAMILY_WALL_PLUS / STRIPE_PRICE_FAMILY_WALL_PLUS_ANNUALfamily_wall_plus and upserts the subscriptions row on checkout.session.completed (same path every tier uses; cancel/update via the existing customer.subscription.* handlers). Settings CTA lives in wall/page.tsx (startPlusCheckout, ?plus=1 return thank-you). Remaining before flipping FAMILY_WALL_PLUS_ENFORCED=true: create the two Stripe prices + set the envs (~$4.99/mo · $39/yr), grandfather early users, then flip the env. (All feature gates — incl. the two cost points, cloned-voice wall messages + kids'-chat — are now wired.)

Family "who's home?" presence board (migration 533)

Per-member presence (home / out / school / work / away) + optional note ("back at 5"), shown as a chip on each wall member card; anyone in the household sets it, and on an allow_checkin wall a tap on a member flips home↔out. family_presence (one row/member, upserted) + family_wall_displays.show_presence (default on). Service family-presence.ts (getPresenceByMember/listMemberPresence/setPresence/togglePresenceFor) + pure family-presence-core.ts (clampPresenceStatus/presenceMeta/togglePresence/sanitizePresenceNote, golden-tested). Endpoints: GET /api/family/presence, PUT /api/family/presence/:memberId (auth → resolveFamilyOwnerId); public POST /api/family/wall/view/:token/presence-toggle (presenceToggleFromWall — allow_checkin only; member re-scoped to the wall's parent; member id exposed on the wall projection only when show_presence && allow_checkin). Manage UI /dashboard/family/whos-home.

Family chore points + allowance (migration 532)

Each completed routine check-in earns a member 1 point; points convert to allowance at a per-family cents-per-point rate; a parent pays out a member (banks the balance as a redemption, resets to 0). Balance = all-time done check-ins − redeemed. family_reward_settings (cents_per_point) + family_point_redemptions (payout ledger) + family_wall_displays.show_points. Service family-rewards.ts (getMemberRewards/getRewardSettings/setRewardSettings/redeemMember/getBalancesByMember) + pure family-rewards-core.ts (computeBalance/pointsToCents/clampCentsPerPoint/formatCents, golden-tested). Endpoints: GET /api/family/rewards, PUT /api/family/rewards/settings, POST /api/family/rewards/redeem (auth → resolveFamilyOwnerId). Wall shows a ⭐-points badge on each member card (per-wall show_points). Manage UI /dashboard/family/points — set rate, per-member balance ($), pay out. (V1 = flat 1 pt/chore; per-chore weighting is a future enhancement.)

Family pets — "have the pets been fed yet?" (migration 530)

Solves the household double-feed problem: the wall shows each pet's today feeding status and anyone taps "just fed" (from the wall or the dashboard) — the count is shared so nobody double-feeds. family_pets (name/emoji/feedings_per_day 1–6) + family_pet_feedings (one row per feeding, fed_date) + family_wall_displays.show_pets (default on). Shared via resolveFamilyOwnerId. Service family-pets.ts (listPetsWithStatus/getWallPets/add/update/delete/feedPet/unfeedPet) + pure family-pets-core.ts (petFeedStatus — fed once count ≥ per-day — + sanitizers/clamps, golden-tested).

  • Endpoints: GET/POST /api/family/pets, PATCH/DELETE /api/family/pets/:id, POST /api/family/pets/:id/feed|/unfeed (auth → resolveFamilyOwnerId).
  • Tap-to-feed from the wall: public POST /api/family/wall/view/:token/pet-feed (petFeedFromWall) — allow_checkin walls only; pet re-scoped to the wall's parent; the item id is exposed on the wall projection only when allow_checkin (like shopping check-off). Allowlisted in the route-auth audit.
  • Wall: "Have the pets been fed?" panel (emoji + name + Fed X/Y, tap target when allowed). Manage UI /dashboard/family/pets — add pet, Just-fed, undo, remove.

Family notes board — shared sticky notes (migration 529)

A shared message center any household member posts to (parent + linked accounts via resolveFamilyOwnerId); notes show as sticky notes on the wall, pinned ones first. family_notes (body/author_label/color yellow|pink|blue|green/pinned) + family_wall_displays.show_notes (default on). Service family-notes.ts (list/add/setNotePinned/delete/getWallNotes) + pure family-notes-core.ts (clampNoteColor/sanitizeNoteBody/sanitizeAuthorLabel, golden-tested). Endpoints: GET/POST /api/family/notes, PATCH (pin) /DELETE /api/family/notes/:id. UI /dashboard/family/notes — post (body + author + color), pin, delete. Wall shows up to 6 (pinned first) as tilted sticky notes.

Family Wall — weather (migration 528)

A weather panel in the wall header via Open-Meteo (free, no API key). Parent types a city on the wall settings → geocoded once (Open-Meteo geocoding, also no key) → weather_lat/weather_lng/weather_label stored; resolveWallView fetches current temp + condition + today's H/L (°F), 15-min in-memory cache. Pure family-weather-core.tswmoToWeather (WMO code → emoji+label, bounded to 0–99) + roundTemp, golden-tested. Service family-weather.ts (geocodeCity/getWeather). Per-wall show_weather + city input on /dashboard/family/wall. NB: migration 528's PR also fixed a latent bug — resolveWallView's .select(...) omitted the toggle columns (show_calendar/meals/shopping/activities), so === true toggles (shopping/activities) never displayed and !== false toggles ignored their "off". Now all toggle columns are selected.

Family meal plan — "what's for dinner" (migration 525)

A weekly dinner planner, shared across the parent + linked family accounts so anyone in the household can fill it in; tonight's dinner shows on the wall. Multi-user sharing works via resolveFamilyOwnerId(userId) (family-access.ts) — a parent uses their own id; a linked family member (family_members.linked_user_id) resolves to that family's parent; so all household accounts key off the same owner.

  • Migration 525 family_meals (one dinner per (parent_user_id, meal_date); title/notes/created_by) + family_shopping_items (shared checklist — service is a follow-up) + family_wall_displays.show_meals. RLS on, no anon policies.
  • Service family-meals.ts (getWeekMeals/getMealForDate/setMeal — empty title clears) + pure family-meals-core.ts (nextDates/sanitizeMealTitle/sanitizeMealNotes, golden-tested). Endpoints: GET /api/family/meals?start=, PUT /api/family/meals/:date (auth → resolveFamilyOwnerId).
  • Planner UI /dashboard/family/meals — 7-day list, inline title + notes, save-on-blur. Wall shows a "Tonight's dinner" panel (per-wall show_meals toggle).

Family shopping list — shared checklist (migration 525/526)

A shared list any family member can add to / check off (same resolveFamilyOwnerId sharing as meals). family_shopping_items (from migration 525). Service family-shopping.ts (listShopping/listUncheckedShopping/add/update(check/rename)/delete/clearCheckedShopping) + pure family-shopping-core.ts (sanitizeItemName/sanitizeQuantity, golden-tested). Endpoints: GET/POST /api/family/shopping, PATCH/DELETE /api/family/shopping/:id, POST /api/family/shopping/clear-checked (all auth → resolveFamilyOwnerId). UI /dashboard/family/shopping — add/check/delete/clear-checked, optimistic. Wall shows unchecked items behind a per-wall show_shopping toggle (migration 526, default off).

Family activities — local events + play ideas + kids' video clips (migration 527)

Curated family inspiration on the wall: local events (with date/place), play ideas (activities), and kids' project videos (paste a YouTube link → the wall shows a thumbnail). Ideas rotate one at a time on the wall (12s). Owner-managed, shared via resolveFamilyOwnerId; hand-curated in v1 (no external events feed / no auto-YouTube ingest).

  • Migration 527 family_activities (kind event|idea, title/detail/url/event_at) + family_wall_displays.show_activities (default off). RLS on, no anon policies.
  • Service family-activities.ts (listActivities/getWallActivities/add/update/delete; getWallActivities returns upcoming events + active ideas with derived YouTube thumbs). Pure family-activities-core.tsyouTubeId (watch/short/embed/shorts URL shapes) + youTubeThumb + sanitizeActivityUrl (drops non-http schemes — a wall is semi-public) + kind/title sanitizers, golden-tested (10 cases). Endpoints: GET/POST /api/family/activities, PATCH/DELETE /api/family/activities/:id (auth → resolveFamilyOwnerId).
  • Wall: "Around town" events list + a rotating "Something to make" idea card (YouTube thumbnail + progress dots), behind per-wall show_activities. Manage UI /dashboard/family/activities.

Community Wall — neighbor meals, Phase 0 read-only (migration 543)

A NEW authenticated neighbor surface — NOT the Family Wall (bright line #2: never a block on the kid-reachable family display). HJ owns the /community surface + consent/safety boundary; PorchHearth owns the engine and HJ only READS it (server-proxied). Phase 0 = read-only homemade-meals feed; posts/claims/handoffs are counsel-gated later phases (none built). Spec: COMMUNITY_WALL_SURFACE_SPEC.md; contract (10 binding bright lines): crosstalk/contracts/community-wall.md.

  • Migration 543 community_membersuser_id PK, coarse centroid only (coarse_lat/coarse_lng, never an exact home point — bright line #1, matches the Citizen Scientist city-centroid rule), city_label, radius_mi (1–15, default 5), tier (browse/verified/trusted — Phase 0 is all browse), verification_method, is_adult. RLS on, no anon policies.
  • Service community.ts: getMembership/upsertMembership (coarsens coords to ~1.1km on write) + getMealsFeed — a server-side proxy of PorchHearth's LIVE public read GET <PORCHHEARTH_API_BASE>/api/v1/public/listings?geo=<lat,lng>&radius_mi=&limit= with a 60s in-memory cache keyed by rounded geo+radius (all wall traffic egresses one IP; PorchHearth throttles 120 req/min/IP). Reads are public (no key). Degrades gracefully: stale cache or empty + engineDown on outage, never throws to the client. Base URL is one env value PORCHHEARTH_API_BASE (default = PorchHearth Railway façade).
  • Routes community.ts (mounted /api/community): GET /membership, POST /membership (coarse area + radius), GET /feed. No write/claim endpoints exist (counsel gate).
  • Frontend: /community (server sets noindex) → CommunityClient.tsx — auth-gated (redirects to signin), coarse-area onboarding (browser geolocation rounded to 2 decimals client-side before send + optional city label + radius slider), meals feed cards (cook, title, price, pickup window, MEHKO/fulfillment badges, coarse city label). Nav: "Community Wall" 🏘️ in FeatureMenu (Journal group, next to Family).
  • Cross-product: coordinated with the PorchHearth session via crosstalk. Phase 1 (GET /public/needs give-aways + mutual-aid) is pre-authorized on PorchHearth's side, built when HJ's surface spec firms; writes stay counsel-gated both sides.

Family Calendar — multiple Google Calendars overlaid (migration 523)

Connect several Google accounts/calendars (mom's, dad's, a team calendar) that overlay on one shared family view; the Family Wall can show it. Reuses the existing Google OAuth (same client + redirect as google-calendar.ts task-sync) — the shared /api/google-calendar/callback branches on a fam: state prefix to route family connects to saveFamilySourceFromCallback. So no new OAuth setup.

  • Migration 523 family_calendar_sources — multiple per parent (parent_user_id, google_email, calendar_id, refresh_token/access_token server-only, label, color, privacy_mode ∈ full/busy, is_active). RLS on, no anon policies — tokens never leave the server. UNIQUE(parent_user_id, google_email, calendar_id) so reconnecting updates. Also adds family_wall_displays.show_calendar (per-wall opt-in).
  • Privacy: a wall is a shared surface, so each source is shown in full (titles) or busy-only (times as "Busy", titles hidden). Per-source + per-wall (show_calendar).
  • Service family-calendar.ts (startFamilyConnect/saveFamilySourceFromCallback/list/update/deleteFamilySource — delete revokes at Google) + pure family-calendar-core.ts (pickColor/labelFromEmail/clampColor/clampPrivacy, golden-tested). Endpoints: GET /api/family/calendar/connect|sources, PATCH|DELETE /api/family/calendar/sources/:id (all auth; return token-free rows).
  • Management UI: /dashboard/family/calendar — connect, per-source color/label/privacy/hide/disconnect.
  • Overlay on the wall: fetchFamilyEvents(parentUserId, timeMin, timeMax) in family-calendar.ts refreshes each active source's token, fetches today's Google events, and — via the pure normalizeGoogleEvent (busy-only sources never emit the title — the shared-wall guard) + sortEvents (golden-tested) — merges them privacy-filtered + color-tagged. 5-min in-memory cache keyed by parent+window so the 60s wall poll (× any viewers) makes ≈one Google call per source per 5 min; a broken source is skipped, never fatal. resolveWallView fetches when the wall's show_calendar is on and passes calendar into the projection. Span (migration 524calendar_days ∈ 1/3/7, clampCalendarDays): the window widens to Today / 3-day / Week; the wall renders a day-grouped agenda (headers "Today"/"Tomorrow"/weekday) with all-day events as colored banner pills and timed events as rows (color dot + time + title + source label, past dimmed). Per-wall show_calendar toggle + span select on /dashboard/family/wall.

Customization

Display Names (Pseudonyms)

Auto-generated or custom display names from attribute + color + animal pools (e.g., "Quiet Blue Fox").

  • Helper: apps/backend/src/utils/displayName.ts
  • Migration: 020_add_display_name_pseudonym.sql

Fonts (Hierarchical)

Per-user, per-notebook, per-entry font + size with hierarchical override (entry > notebook > user).

  • Migrations: 010_add_font_options.sql, 011_add_hierarchical_font_settings.sql
  • Frontend helper: apps/frontend/src/lib/fonts.ts (resolveFont, resolveFontSize)

Cover & Background Images

Curated cover images for notebooks, time-of-day backgrounds for the stream view.

  • Backend: apps/backend/src/routes/covers.ts · stream-backgrounds.ts
  • Migration: 013_add_background_image.sql
  • Assets: apps/frontend/public/images/covers/, apps/frontend/public/images/stream-backgrounds/

Theme & Site Settings

Light/dark theme via ThemeContext, plus admin-controlled site-wide settings.

  • Frontend context: apps/frontend/src/contexts/ThemeContext.tsx
  • Wrapper: ThemeProviderWrapper.tsx
  • Migration: 009_add_site_settings.sql

Inactivity fireplace screensaver

Full-screen ambient hero video (same one the navbar 🔥 button plays via getHeroVideo) that fades in after ~2 min idle across both HiveJournal and Graphene; any input wakes it (no navigation, unlike the 🔥 tap modal). Mounted once globally in ClientOnlyComponents.tsx so it covers every brand. Component: FireplaceScreensaver.tsx; idle detection: lib/useInactivity.ts (visibility-aware). Default ON. Gating: signed-in → profiles.preferences.screensaver_enabled (default on, absent reads as enabled) toggled at /dashboard/settingsFireplace screensaver card; signed-out → on by default with an in-overlay "Turn off" link writing localStorage.hj_screensaver_off (the settings toggle mirrors that key so the two stay in sync). Never fires on prefers-reduced-motion, on /+/landing+/variant- (already show the fireplace), or chrome-free embeds. Endpoints: GET/PUT /api/user/screensaver-preferences (user.ts). No migration — uses the existing profiles.preferences JSONB bag.

Per-user feature flags

Users can hide dashboard features they don't use from /dashboard/settingsFeatures card. Turning a feature off skips the corresponding fetch on the next dashboard load (no data is deleted; re-enabling restores the widgets).

  • Schema: migration 077_profile_feature_flags.sql adds profiles.feature_flags JSONB NOT NULL DEFAULT '{}'. Open-map shape — new keys don't require a migration, just a code reference in the allowlist + toggle meta.
  • Backend: GET /api/user/feature-flags + PATCH /api/user/feature-flags in apps/backend/src/routes/user.ts. Allowlist: hide_sleep_tracking / hide_mood_tracking / hide_action_tracking / hide_workout_window (unknown keys are stripped).
  • Frontend hook: apps/frontend/src/lib/useFeatureFlags.tsuseFeatureFlags() hook + peekFeatureFlags() synchronous reader backed by sessionStorage cache so the dashboard first-paint gating doesn't wait on a round-trip.
  • Settings UI: FeatureTogglesSection in apps/frontend/src/app/dashboard/settings/page.tsx — card with per-feature toggle + icon + description. On/Hidden pill, animated switch, optimistic update with revert-on-error.
  • Dashboard wire-up: apps/frontend/src/app/dashboard/page.tsx — satisfaction / actions / sleep side-stream fetches now short-circuit when the matching flag is set.

User Profile

Profile management (avatar, display name, preferences).

  • Backend: apps/backend/src/routes/user.ts
  • Frontend: /dashboard/settings

Open Energy (legacy)

Participatory science pathway — 768 patents and 29 experiments organized as constellations users can light up by participating. Has been unified with DreamPro: the experiments now exist as research-grade dreams templates and the public-facing community layer lives at /dreampro. The legacy /open-energy routes still exist but the active development is under the DreamPro Citizen Science Platform below. See features/open-energy.md for the original 10-phase pathway and features/dreampro-citizen-science.md for the current platform.

Public landing (legacy)

  • apps/frontend/src/app/open-energy/page.tsx
  • Layout (metadata): apps/frontend/src/app/open-energy/layout.tsx
  • Manifest (PWA): apps/frontend/src/app/open-energy/manifest.ts
  • Route: /open-energy

Constellation experiments page

  • apps/frontend/src/app/open-energy/experiments/page.tsx
  • Badges: badges.ts
  • Route: /open-energy/experiments

Top contributors leaderboard

  • apps/frontend/src/app/open-energy/contributors/page.tsx
  • Route: /open-energy/contributors

Backend

  • apps/backend/src/routes/open-energy.ts — progress, replications, builds, contributors, activity, OG, admin seed

Migrations

  • 046_open_energy_progress.sql — per-user experiment status (locked/available/in_progress/completed)
  • 047_open_energy_replications.sql — confirm/refute records toward 50-confirmation goal

Admin

  • apps/frontend/src/app/dashboard/admin/open-energy/page.tsx — seed generators + stats

OG image routes

  • /api/og/open-energy
  • /api/og/open-energy/experiments
  • /api/og/open-energy/contributors

Reference


DreamPro Citizen Science Platform

Public-facing community layer built on top of the unified DreamPro/Open Energy templates. Phases 1–8 cover everything from a privacy-respecting world map of participants to an annual competition to K-12 classroom support. See features/dreampro-citizen-science.md for the full deep dive.

Landing & navigation

  • apps/frontend/src/app/dreampro/page.tsx — Public DreamPro landing with cards for all 8 phases
  • Route: /dreampro

Templates browse + detail

  • apps/frontend/src/app/dreampro/templates/page.tsx — Browse all research-grade templates
  • apps/frontend/src/app/dreampro/templates/[id]/page.tsx — Detail page (clone, replicate, build, video submission)
  • apps/frontend/src/app/dreampro/templates/classroom/page.tsx — Classroom-safe template browse
  • Routes: /dreampro/templates, /dreampro/templates/[id], /dreampro/templates/classroom

Phase 1 — Citizen Scientist World Map

  • Page: apps/frontend/src/app/dreampro/map/page.tsx (dynamic-imports the Leaflet inner component)
  • Inner: apps/frontend/src/components/dreampro/map/WorldMapInner.tsx
  • Classroom markers are coloured emerald (#34d399) instead of the participant's pattern colour, so K-12 classrooms are visually distinguishable at a glance from individual citizen scientists. Pattern affiliation still appears on the popup chips. Legend strip under the map calls this out. The existing Kind filter chip row (Citizen Scientists / Family Teams / Classrooms / Meetup Groups) lets users isolate them.
  • Upcoming meetup events render as ephemeral amber-dashed pins layered on top of permanent participant markers. Sourced from GET /api/dreampro/meetup-events (already public), keyed off the host meetup participant's fuzzed lat/lng. Popup shows title, start/end, location_text, host meetup, RSVP count, and a "View details" link to /dreampro/meetups/[id]. Toggle in the filter row (Show upcoming events, default on) hides them. The "Upcoming events" stat tile replaces the old "Meetups" tile.
  • Opt-in form: apps/frontend/src/app/dreampro/citizen-scientist/page.tsx
  • Privacy floor: apps/backend/src/utils/geoFuzz.ts (~5km grid + per-user deterministic jitter)
  • Backend: participants table + POST /api/dreampro/participants/opt-in, GET /participants/map, GET /participants/me, DELETE /participants/:id
  • Migration: 054_participants_and_competitions.sql
  • Seed: apps/backend/scripts/seed-citizen-scientists.ts
  • Routes: /dreampro/map, /dreampro/citizen-scientist

Phase 2 — Annual Open Energy Competition

  • Page: apps/frontend/src/app/dreampro/competition/[slug]/page.tsx — leaderboard with kind tabs + sponsor rail + admin recompute trigger
  • Scoring service: apps/backend/src/services/dreampro/competition-scorer.ts (idempotent, manual recompute, time-windowed scoring)
  • Backend endpoints: GET /competitions/:slug/leaderboard, POST /competitions/:slug/recompute (super-admin only)
  • Auto-enrolls participants on opt-in. Default weights: clone=1, completion=5, replication_confirmed=25, replication_refuted=5, build=10
  • Migration: 054_participants_and_competitions.sql (seeds open-energy-2026)
  • Route: /dreampro/competition/[slug]

Phase 3a — Sponsors

  • Wall: apps/frontend/src/app/dreampro/sponsors/page.tsx
  • Program landing: apps/frontend/src/app/dreampro/sponsorship/page.tsx
  • Application form: apps/frontend/src/app/dreampro/sponsors/apply/page.tsx
  • Backend: sponsors + sponsorships tables (M2M, target_kind=platform/competition/meetup/classroom/individual)
  • Endpoints: GET /sponsors, POST /sponsors/apply, POST /admin/sponsors/:id/approve
  • Six tiers: platinum / gold / silver / bronze / in_kind / community
  • Directory model only — no payment processing. contact_email and contact_name are admin-only and never returned by public endpoints
  • Migration: 055_sponsors.sql
  • Seed: apps/backend/scripts/seed-sponsors.ts
  • Routes: /dreampro/sponsors, /dreampro/sponsorship, /dreampro/sponsors/apply

Phase 3b — Opportunities (internships, scholarships, fellowships, mentorships)

  • Browse: apps/frontend/src/app/dreampro/opportunities/page.tsx
  • Detail: apps/frontend/src/app/dreampro/opportunities/[slug]/page.tsx
  • Backend: opportunities table, same admin-approval workflow as sponsors
  • Endpoints: GET /opportunities (filterable), GET /opportunities/:slug, POST /opportunities, admin approve/decline
  • Migration: 056_opportunities.sql
  • Seed: apps/backend/scripts/seed-opportunities.ts
  • Routes: /dreampro/opportunities, /dreampro/opportunities/[slug]

Phase 3c — Sponsorable Participants

  • Directory: apps/frontend/src/app/dreampro/sponsorable/page.tsx (with inline outreach modal)
  • Extends participants with seeking_sponsorship, sponsorship_pitch, external_links (JSONB, 6-link cap)
  • New sponsor_outreach table for Resend-relayed messages
  • Endpoints: GET /participants/sponsorable, POST /participants/:id/outreach (rate-limited 5/24h per sender per recipient)
  • Recipient email is never exposed to the sender — platform forwards via Resend with sender's reply-to
  • Migration: 057_sponsorable_participants.sql
  • Route: /dreampro/sponsorable

Phase 3c — Weekly outreach digest emails

On top of the per-message relay, sponsorable participants get a weekly Monday rollup of the last 7 days of inbound sponsor messages — cumulative count, unique sender count, reply rate (since migration 074), and the 5 most recent subjects + sender labels (each tagged with a "REPLIED" badge if the recipient marked it replied in their inbox) — delivered via Resend with a CTA to the sponsor inbox.

  • Backend cron endpoint: POST /api/dreampro/cron/sponsor-outreach-digest in apps/backend/src/routes/dreampro.ts — secured by x-cron-secret. Computes the most recent UTC Monday as week_start_at, looks back the prior 7 days, groups by recipient, and sends one digest per opted-in recipient with a linked_user_id.
  • Vercel cron route: apps/frontend/src/app/api/cron/dreampro/sponsor-outreach-digest/route.ts, scheduled 0 9 * * 1 (Mondays 9am UTC) in vercel.json.
  • Idempotency: migration 072 adds sponsor_outreach_digests (participant_id, week_start_at) UNIQUE — the cron INSERTs the claim row before the email send. Re-runs in the same week conflict and skip silently. The row also captures delivered + delivery_error for forensics.
  • Opt-out: participants.weekly_outreach_digest_opt_out BOOLEAN DEFAULT FALSE (added in 072). Per-message relays are unaffected — only the digest is suppressed.
  • Migrations: 072_sponsor_outreach_digests.sql, 074_sponsor_outreach_reply_tracking.sql (adds replied_at + reply_notes columns + an UPDATE policy so recipients can mark their own outreach as replied).

Phase 3c — Sponsor inbox + reply tracking

Recipient-facing view of every sponsor message addressed to their participant profile, with a one-click "Mark replied" toggle that feeds the weekly digest's reply-rate stat. Replies happen off-platform via each sender's reply_to_email; this UI is the platform's only signal that a reply happened.

  • Frontend page: apps/frontend/src/app/dashboard/dreampro/sponsor-inbox/page.tsx — stat strip (total / last 7d / replied / reply rate), filter chips (all / unreplied / replied), per-message card with subject + body + sponsor tier + reply-to mailto link + "Mark replied" button. Replied messages can carry an optional 500-char private note ("offered $5K, awaiting response").
  • Backend endpoints in dreampro.ts: GET /api/dreampro/my-outreach (list + stats, scoped by participants.linked_user_id == auth.uid()); PATCH /api/dreampro/my-outreach/:id (toggle replied_at and update reply_notes, ownership re-verified before write).
  • Discoverability: link surfaced on the citizen-scientist profile page when the user is currently seeking sponsorship and has an existing participant row.
  • Navbar unread badge — when the user has any unreplied outreach, an amber-badged 📬 icon appears in the navbar next to the Stream icon, polling every 30s (same cadence as the unread-drops badge). Hidden at 0 to avoid cluttering the navbar for users who aren't sponsorable. Powered by GET /api/dreampro/my-outreach/unread-count (auth-gated, soft-fails to 0 so a hiccup never blocks navbar render).
  • Route: /dashboard/dreampro/sponsor-inbox

Phase 4 — Local Meetup Groups & Events

  • Browse: apps/frontend/src/app/dreampro/meetups/page.tsx
  • Detail: apps/frontend/src/app/dreampro/meetups/[id]/page.tsx (with inline event-create modal for organizers)
  • Organizer opt-in: apps/frontend/src/app/dreampro/meetup-organizer/page.tsx
  • Backend: meetup_events + meetup_event_rsvps tables, meetup_event_rsvp_counts view
  • Endpoints: GET /meetups, GET /meetup-events (global feed, MUST be defined BEFORE /meetups/:id), GET /meetups/:id, POST /meetups/:id/events, PUT /meetup-events/:id, POST /meetup-events/:id/rsvp
  • Events optionally link to a template; RSVPs toggleable; public counts but private per-user records
  • Migration: 059_meetups.sql
  • Seed: apps/backend/scripts/seed-meetups.ts
  • Routes: /dreampro/meetups, /dreampro/meetups/[id], /dreampro/meetup-organizer

Phase 5 — K-12 Classrooms (COPPA-safe)

  • Opt-in: apps/frontend/src/app/dreampro/classroom/page.tsx (teacher-only, two required consent checkboxes)
  • Public classroom page: apps/frontend/src/app/dreampro/classrooms/[id]/page.tsx
  • Progress modal: apps/frontend/src/components/dreampro/ClassroomProgressModal.tsx
  • Schema: adds dreams.safety_level ('adult_only' | 'with_supervision' | 'classroom_safe', defaults adult_only) and dreams.min_grade_band. New classroom_progress_reports table (UNIQUE on classroom × template, upsert pattern)
  • Endpoints: GET /templates/classroom (only promoted templates, MUST be before /templates/:id), POST /admin/templates/:id/promote-to-classroom (super-admin), GET /classrooms, GET /classrooms/:id, POST /classrooms/:id/progress-reports (teacher-only)
  • HARD RULES: No student data ever. Bucketed counts only (10/20/30/40). Templates default adult_only. Progress reports class-as-a-unit. Teacher must own the classroom. No photos/files/DMs.
  • Migration: 058_classrooms.sql
  • Routes: /dreampro/classroom, /dreampro/templates/classroom, /dreampro/classrooms/[id]

DreamPro Junior — Kit Waitlist (validation gate, not yet a product)

Pre-build demand validation for a proposed quarterly STEM kit subscription (Box 1 Coil Geometry → Box 2 Resonance → Box 3 Pulsed DC → Box 4 Synthesis). Four boxes a year, each a beginner-friendly slice of the 768-patent meta-pattern. Decision gate: >200 signups within 30 days of launch = green-light the kit-subscription build (Stripe, kid-safe QR landings, parent-mediated replication submission). Below 50 = shelve.

  • Public landing: apps/frontend/src/app/dreampro/kits/page.tsx — pitch + four-box grid + email-capture form (parent email, optional kid age bands 5-8 / 9-12 / 13-17, optional country code, optional notes). No kid PII. Successful submit redirects to a thank-you panel; idempotent on email (UPSERT).
  • Admin view: apps/frontend/src/app/dashboard/admin/kit-waitlist/page.tsx — super-admin only. Shows the 200-signup decision gate with day-since-launch + projected-at-day-30, age-band stat strip, country breakdown, full signup table.
  • Backend: POST /api/dreampro/kit-waitlist (public, validates email + bucketed age bands), GET /api/dreampro/kit-waitlist (super-admin only). Both in apps/backend/src/routes/dreampro.ts.
  • Migration: 073_kit_waitlist.sqlkit_waitlist table with UNIQUE(email), bucketed kid_age_bands TEXT[], optional 2-char country_code, RLS-on with no public policies (service-role-only access).
  • Discovery: card on the public DreamPro landing under the third row of phase cards.
  • Routes: /dreampro/kits, /dashboard/admin/kit-waitlist
  • Public waitlist counter/dreampro/kits shows a live "N families on the waitlist · M this week" badge above the four-box grid for social proof. Powered by a public unauthenticated GET /api/dreampro/kit-waitlist/count (counts only, no PII, 60s Cache-Control). Badge hides when total is 0 to avoid showing an empty-state.
  • Tasks tracked under category "DreamPro Junior Kits" in product_tasks. P3 implementation tasks (kit SKUs, Stripe wiring, kid-safe QR landings, parent-mediated submission) are blocked on the decision gate.
  • Box themes spec: docs/product/DREAMPRO_JUNIOR_KIT_THEMES.md — full one-pager per box (Coil Geometry / Resonance / Pulsed DC / Synthesis) with learning objective, kid procedure, patent cluster mapping, BOM with cost targets, sponsor pitch hook, parent-facing email-sequence narrative, and two resolved strategic decisions: NanoVNA inclusion model (Year 1 = separate Tools Kit) and standalone-vs-subscription model (Year 1 = subscription + de-emphasised standalone Box 1 upsell).
  • Unit economics: docs/product/DREAMPRO_JUNIOR_UNIT_ECONOMICS.md — per-starter contribution math, sensitivity grid (price tier × churn × Tools attach × fulfillment cost), break-even subscriber counts (139 at baseline, 273 worst-case), three Year-1 P&L scenarios (100 / 250 / 500 subscribers), and the concrete sponsor pitch dollar value ($2,800 to underwrite 100 NanoVNAs). Recommends raising the waitlist decision gate from 200 → 300 signups for confident break-even.
  • Sponsor pitch drafts: docs/product/DREAMPRO_JUNIOR_SPONSOR_PITCHES.md — three ready-to-send email pitches, each calibrated to the recipient's existing community-support pattern: Adafruit (educational-content angle, ALS cross-publishing), SparkFun (post-SIK successor, K-12 channel resale), Digi-Key (multi-year, FIRST tie-in, formal review). Recommends staggered send order SparkFun → Adafruit → Digi-Key over one week.
  • Fulfillment quote requests: docs/product/DREAMPRO_JUNIOR_FULFILLMENT_QUOTES.md — three quote-request emails (Cratejoy / ShipBob / maker-space) with a shared shipment-specification block so quotes are apples-to-apples comparable. Includes a decision rule keyed off the ShipBob price (< $9/box → default 3PL; > $11/box → push maker-space). Per-box fulfillment is the highest-leverage unknown in the unit-economics model.

Phase 6 — Family Teams

  • Opt-in: apps/frontend/src/app/dreampro/family/page.tsx
  • No new schema (kind='family' already exists from migration 054)
  • Bucketed kid age bands (5-8 / 9-12 / 13-17), has_kids flag, adult contact consent
  • Route: /dreampro/family

Phase 7 — Manual Scoring Trigger (cron deferred)

  • Super-admin recompute button on the competition leaderboard page (Phase 2 entry above)
  • Endpoint: POST /api/dreampro/competitions/:slug/recompute — calls recomputeCompetition() from the scoring service
  • Future: nightly cron iterating competitions WHERE is_active AND entry_open

Phase 8a — Dream Videos

  • Display: video section on templates/[id]/page.tsx (16:9 iframe via youtube-nocookie.com, featured + upvote count badges)
  • Submission: VideoSubmissionModal.tsx
  • YouTube parsing: apps/backend/src/utils/youtube.ts (extracts ID from watch/shorts/embed/youtu.be, fetches oEmbed metadata — no API key)
  • Backend: dream_videos + dream_video_upvotes tables
  • Endpoints: GET /videos?template_id=X, POST /videos (rate-limited 3/24h, classroom accounts blocked), POST /videos/:id/upvote, admin approve/decline
  • HARD RULES: Classrooms cannot submit. URL must extract valid 11-char YouTube ID. oEmbed must succeed (rejects private/deleted). One pending+approved per (template, submitter). Embeds via youtube-nocookie.com. Directory only — never re-host video files.
  • Migration: 060_dream_videos.sql

Phase 8b — DreamPro Channel landing (not yet built)

  • Waiting on the actual YouTube channel existing
  • Will live at /dreampro/videos with downloadable Creator Kit assets

Integrations

Universe canon library (characters + places)

Phase 1 of the short-film-studio roadmap (docs/product/SHORT_FILM_STUDIO.md). Dedicated universe_characters + universe_places tables (migration 393_universe_canon_library.sql, super-admin RLS + updated_at triggers) — a shared, image-bearing, versioned canon per universe so a character/place is designed once and reused across every chapter + show (and, later, cross-pollinated into Scene Studio). Authored canon still lives in story_universes.canon; these tables hold the resolved library + images + versions[]. Service universe-canon.ts: listUniverseCharacters/Places, upsert*, delete*, and importCanonToLibrary(key) (idempotent seed from canon.characters[]/settings[] + existing universe_character_portraits — handles object-or-string canon entries). Routes on /api/story-seasons (super-admin): GET/POST/DELETE /universes/:key/{characters,places} + POST /universes/:key/canon/import. Image generation (universe-canon-gen.ts, Phase 2): generateUniverse{Character,Place}Image reuse Scene Studio's now-exported gen primitives (keyframeBuffer/kontextBuffer/kontextStyleBuffer/describeImageStyle) to render in the universe's visual_style + style_reference_image_url, versioned + budget-capped (universe_canon.*, env UNIVERSE_CANON_DAILY_CAP_USD default $15); routes POST /universes/:key/{characters/:charKey,places/:placeKey}/image[/upload|/select]. Editor UI (Phase 2b): UniverseCanonPanel.tsx renders inside UniverseEditorModal when editing a saved universe — an "Import from canon" seed button + character/place tiles with Generate/Regen, upload, version chips, and click-to-enlarge. Scene Studio cross-pollination (Phase 3): the scene resolves its universe (via story_seasons.universe); the character bible gets ⇪ Import from universe (pull canon character images by name into scene_projects.characters) + per-character ⇪ to universe (promote a restyled ref into universe_characters); each shot gets a 📍 place selector (migration 394_scene_shot_place.sqlscene_shots.place_key) and shotKeyframeBuffer conditions the keyframe on that place's image (multi-image Kontext: character subject + place setting) so backgrounds stay consistent across shots/chapters. Routes: GET /projects/:id/places, POST /projects/:id/references/{import-universe,save-universe}.

  • Standalone-show canon (per-season namespace): shows NOT in a universe now get their own characters/places. getSceneProject returns canon_key = universe || season:<id> (the universe_characters/universe_places key is free-form, no FK), and listScenePlaces / placeImageMap / import / save-to-canon all use canon_key, so standalone shows pull characters + places into Scene Studio exactly like universe shows. universe-canon-gen's universeStyle resolves a season:<id> key to the season's visual_style + poster (so canon art matches the show). LLM seed: seedSceneCanonFromSeason (POST /projects/:id/canon/seed, gpt-4o-mini, metered scene_studio.canon_seed) extracts characters + places from the premise + chapter prose into the canon namespace. UI: the Places panel is now shown for every show with a ✨ Seed characters & places action + per-place ✨ image / generate (calls the universe-canon image route with canon_key). Both surfaces: also managed on the Graphene season pageSuperAdminPanel renders UniverseCanonPanel for standalone seasons (universeKey=season:<id>, seasonSeedId=<id>), where its Import button becomes ✨ Seed from storyseedSeasonCanon (POST /api/scene-studio/seasons/:id/canon/seed, picks the season's first chaptered prose). Same season:<id> namespace Scene Studio reads, so edits in either surface show in both. Batch image gen ("✨ Generate all" in UniverseCanonPanel): generateAllUniverseCanonImages(canonKey, {regenAll, onProgress}) (universe-canon-gen) walks every character then place, filling in MISSING images by default (or regenAll), per-item try/catch, stops early on the daily budget cap. Season path POST /api/scene-studio/seasons/:id/canon/generate-images is durable — wrapped in a season_pipeline_events 'canon_images' stage (startEvent/tracker.progress/complete), so it survives a Railway restart (autoCleanStuckEvents recovers stuck rows) and shows live in the global ActivePipelinesWidget (🖼️ Canon images). Universe-editor path POST /api/story-seasons/universes/:key/canon/generate-images is fire-and-forget (no season event); the panel polls the canon list to watch images land in both cases.
  • Found a universe from a standalone story — turn a story that isn't in a universe into the first entry of a brand-new one. createUniverseFromStory(seasonId, createdBy) (story-universes-db.ts) derives a unique key from the season title (via normalizeKey + a de-collision suffix), sets label=title / description=premise / canon.synopsis_hint=premise / generated_via='inspired_by', and calls the existing createUniverse. Route POST /api/story-seasons/:id/found-universe (super-admin) creates it then attaches the story (story_seasons.universe = key), 400ing if the story is already in a universe. UI: a 🌌 Found a universe from this story button in SuperAdminPanel (novel-mode, only when the season is standalone), sitting beside the existing universe attach/detach picker. Pulling canon (characters/places/themes) from the founding story is the existing canon-harvest flow — now unlocked because season.universe is set (generateCanonCandidates/promoteCanonCandidates in canon-harvest.ts require it).
  • Story-level reuse across scenes: assets generated in one scene are shared with every other scene of the same story. (a) Voices — the per-character voice picker (setSceneCharacterVoice) now writes the STORY level (season_character_voices via upsertCharacterVoice) and clears any per-scene override, so a voice set in one chapter resolves everywhere; the card shows a ★ story badge. (b) Character artgenerateCharacterReferences / regenerateCharacterReference / uploadCharacterReference call seedCanonFromSceneChars to FILL canon gaps (never overwrite existing canon art — explicit "⇪ to universe" still does that), so the first scene to design a character seeds the whole story; getSceneProject returns story_ref_image_url per character (canon match by name) and the bible shows it as a ★ story fallback when the scene has no ref yet. (c) StylegetSceneProject returns show_style_reference_url (the season's shared style reference) so the Style section flags it. (d) Places were already story-level canon.

Slack (DMs + channel messages)

Bot-token integration for ad-hoc contributor/ops messages (and the primitive future auto-notifications build on). services/slack.tssendSlackDM(idOrEmail, text) (resolves a U… member ID or email via users.lookupByEmailconversations.openchat.postMessage), postSlackChannel(channel, text), slackEnabled(). Best-effort, never throws — unset SLACK_BOT_TOKEN = no-op ({ ok:false, skipped:true }); failures go to service_errors (service slack). Routes slack.ts (super-admin): GET /api/slack/status, POST /api/slack/send ({ to } DM or { channel } + text). CLI: scripts/slack-dm.mjs (node scripts/slack-dm.mjs <U…|email|#channel> "msg", dependency-free). Setup: docs/setup-guides/SLACK_INTEGRATION.md (app, scopes chat:write + users:read.email, SLACK_BOT_TOKEN). Auto-DM-on-PR-merge etc. wire a GitHub webhook/Action to POST /api/slack/send — no new Slack plumbing.

Spotify

OAuth + scheduled sync of recently-played tracks for journaling context.

  • Backend: apps/backend/src/routes/spotify.ts
  • Frontend: auth/spotify/callback
  • Migration: 014_add_spotify_integration.sql
  • Cron: api/cron/spotify/sync-all
  • Reference: docs/SPOTIFY_SETUP.md

Email (Resend)

Transactional email via Resend.

New-signup owner alert

Emails the owner when new users sign up. Supabase auth is client-side (no server-side signup hook), so signup-owner-notify.ts runSignupOwnerNotifyTick() polls auth.users on the welcome-drip cadence (15min) and alerts on anyone created since the last check. State is a single site_settings watermark (signup_owner_notify_last) — no migration, no per-user stamp; the first run seeds the watermark and sends nothing (never blasts the existing base on first deploy). Excludes signup-canary probe accounts. Recipient SIGNUP_NOTIFY_EMAIL (default sandonjurowski@gmail.com), from SIGNUP_NOTIFY_FROM; no-op without RESEND_API_KEY. Cron signup_owner_notify in CRON_REGISTRY (default ON — it only emails the owner) + signup_owner_notify_tick heartbeat + kill switch at /dashboard/admin/crons. Scans up to 2000 users/tick (logs if capped — move to a created_at-indexed query at larger scale). Wired in index.ts.

Stripe (Payments)

Subscription billing.

  • Backend: apps/backend/src/routes/payment.ts
  • Frontend: dashboard/subscription

Data Import (Firebase, MongoDB)

Bulk import of legacy data.

Mobile App (React Native / Expo)

Mobile companion app sharing core features.

Chrome Extension

Browser extension for quick capture.

  • App: apps/chrome-extension/
  • Install CTA pre-wiring (lib/extension-install.ts + consumers on /jq-extension + SiteFooter.tsx): the extension isn't published to the Chrome Web Store yet, but every "Install for Chrome" CTA across the site is wired against a single source of truth — getChromeWebStoreUrl() reads NEXT_PUBLIC_CHROME_WEB_STORE_URL. When the env var is unset (private beta — today), surfaces render the existing "load unpacked" guidance + scroll-to-#install fallback. When set to the canonical Web Store URL (e.g. https://chromewebstore.google.com/detail/<extension-id>), the JQ extension hero CTA + install section + a new gradient "Add JQ to Chrome" footer row all become real anchors (target="_blank" + extension_install_clicked PostHog event with a source property to distinguish funnel stages). Defensive validation rejects values not starting with https://. Going live is a single Vercel env-var change + redeploy (~60s) — no code changes needed.

JQ Form Filler (extension autofill from an on-device notebook)

"Give it to JQ, he lives for that sh*t." The extension fills web forms from things JQ knows about you. Path A v1 built 2026-07-15, not yet shipped. Strategy: the extension owns screen forms; the glasses own off-screen (paper/kiosk) forms; Path C (scan → hosted fillable form) bridges them. Full product doc: docs/product/JQ_FORM_FILLER.md.

  • Privacy model (the whole point): the "notebook" lives in chrome.storage.local on the user's device. Only the form's field shape (label/name/type/autocomplete/select-options) and the notebook keys (never values) are sent to the backend for mapping; values are resolved and filled client-side. Password / SSN / payment fields are hard-excluded on both client and server.
  • Backend service: apps/backend/src/services/jq-form-fill.tsmapFormFields() asks gpt-4o-mini (feature_key jq.form_fill.map) which notebook key each field wants; validates output against the allowed key set + known field ids, clamps confidence, drops forbidden fields defensively.
  • Route: POST /api/jq/form-fill/mapapps/backend/src/routes/jq-form-fill.ts, registered at /api/jq/form-fill in index.ts (auth via authenticateUser).
  • Extension pieces: notebook-schema.js (canonical on-device fields, shared by background + side panel), formfill.js (side-panel notebook editor + "Fill this page's form"), scanner/fill-applier + review bar (never submits; empty-fields-only; Undo) in content.js, orchestration in background.js (FILL_ACTIVE_FORM).
  • Test: apps/backend/src/services/tests/jq-form-fill.test.ts locks the guardrails (forbidden fields stripped pre-model, key allow-listing, unknown-id drop, confidence clamp). Run ESM tests with NODE_OPTIONS=--experimental-vm-modules (npm test sets it).

Path C — Scan-to-Form (JQ Form Creator), v1 built 2026-07-15. Upload a photo of a paper form → a vision model (gpt-4o) reconstructs its fields → review/edit → save → a hosted, shareable, fillable web form. Because a hosted JQ form is a normal web form, the Path A extension autofills it for free — the two halves are one pipeline.

  • Migration: supabase/migrations/480_jq_forms.sqljq_forms (owner, title, fields jsonb schema) + jq_form_responses (form_id, respondent_id nullable, values jsonb). RLS on, no public policies — all access via the backend.
  • Backend service: apps/backend/src/services/jq-form-creator.tsextractFormFromImage (vision, image_url accepts an http(s) URL or a data:image/ URI), normalizeFormSchema (the deterministic core: stable unique ids, type clamp, options only for select/radio, drop empty-label + password/SSN/payment fields), and form/response CRUD.
  • Route: apps/backend/src/routes/jq-forms.ts at /api/jq/formsPOST /extract, POST /, GET /, GET /:id (public), PATCH /:id, POST /:id/responses (public), GET /:id/responses (owner). Mounted with a 15mb JSON limit in index.ts so a scanned image can arrive as a data URI.
  • Frontend: creator at apps/frontend/src/app/dashboard/forms/page.tsx (upload → extract → edit → save → share link + inline responses); public hosted fill page at apps/frontend/src/app/forms/[id]/page.tsx + FillFormClient.tsx.
  • Test: apps/backend/src/services/tests/jq-form-creator.test.ts locks normalizeFormSchema.

Quote of the Day (cached daily quote — QuickSites block)

The readable stream rotates live quotes via GET /api/quotes/random (routes/quotes.ts) → external ZenQuotes (one call/request). The embeddable quote of the day is the cacheable sibling: GET /api/quotes/daily?ref=<embedder>{ quote, author, date }, fetching ZenQuotes at most once per UTC day (daily_quotes cache, migration 501) and serving every site + visitor from that row — so external cost is ~1 call/day regardless of scale. Per-ref daily hits tracked in quote_usage (analytics + future metering; the feature is instrumented + chargeable "if needed" though caching makes it ~free). Service services/daily-quote.ts: pure todayUtc (cache key, golden-tested), getDailyQuote (miss → ZenQuotes → upsert-ignore-dup → re-read; ZenQuotes-down + no row → most-recent-prior-day fallback, never hard-fails a public embed), recordQuoteHit (best-effort), getQuoteUsage. CDN cache headers on the route. Contract: crosstalk/contracts/quote-of-the-day.md (QS builds the block). /random stays for the interactive stream. Billing path if a personalized/LLM quote tier ever lands: the About That paid-mode dark-flag pattern. Apply migration 501.

AisleAsk (shopping list → store-section route)

Owner idea 2026-07-17. A HiveJournal-maintained shopping list mapped to store sections; the sections carry a walk order (sort_order), so ordering items by their section gives the quickest route through the store. Phone/web v0 (buildable now, no glasses); the AR-glasses turn-by-turn north star later reads the same GET /:store/route and speaks each next item via /api/jq/voice/saysession.audio.playAudio. The crowd-cataloging supply (paid "store walkers") is the wedge vertical of the crosstalk §10 Odd-Jobs Board (QS-agreed sequencing). Own small tables (migration 500): aisleask_stores (per-user, auto-default), aisleask_sections (store_id, name, sort_order), aisleask_items (user_id, store_id, name, section_id nullable, checked + checked_at trigger); RLS service-role-only (matches Downstream, so the glasses backend reads with the same posture).

  • Public product pages (marketing, frontend-only): hub /aisleask (overview + two doors) → shopper page /for-shoppers + service/B2B page /aisleask/business (operated-service pitch, "measured pilots," no delivery-platform partnership claimed); also in the /point-seven-studio PRODUCTS array + the /features marquee.
  • Engine (pure, golden-tested): services/aisleask.ts orderItemsByRoute(items, sections) — groups items by section, orders stops by sort_order, unplaced items bucket LAST under "Unsorted", empty sections dropped, name-sorted within a stop. Tests: aisleask.test.ts (4/4). Store/section/item CRUD + getRoute (active/unchecked items only) in the same service.
  • Routes: routes/aisleask.ts at /api/aisleask (all authenticateUser, per-user): stores, POST /:storeId/set-default (mark the glasses-active store), GET /nearby-stores?lat&lng (OpenStreetMap Overpass grocery lookup — nearest-first, free/no-key, requires a User-Agent; autofills name+address+coords), sections (+ /reorder), items (+ PATCH check/section), GET /:storeId/route, and GET /:storeId/next (the glasses hook). Page: /dashboard/aisleask — a store switcher (chips to switch stores · + New store · 📍 Find nearby → tap a result to create a pre-filled store · ⭐ = active for glasses / "Set active for glasses") + List / Store-layout (add + ↑↓ reorder sections) / 🛒 Shop (route order, tap to check off), phone-friendly. Apply migration 500.
  • One-pass multi-source merge ("one or more lists + one or more recipes → one route", no migration): POST /:storeId/import-sources { recipes: [{url|text|image_base64}], lists: [string] }importSources extracts every recipe (≤10, cost-guarded) + splits every pasted list, folds them via pure mergeShoppingSources into one deduped set where each item remembers every source that needs it (eggs for Pancakes AND Frittata = one Dairy stop tagged with both), then adds new items / enriches existing ones (appends the new source to item.source via pure mergeSourceLabels rather than silently dropping the dup — provenance survives). Returns { sources, merged, added, enriched, failed }. The same enrich-on-dup fix also lands in single-recipe importRecipe (a dup is now tagged, not just skipped). Both mergeShoppingSources + mergeSourceLabels golden-tested. Dashboard: "⛃ Combine recipes & a list in one pass" panel (N recipe inputs + a list textarea → "Build my route"); the merged provenance renders as the "· Pancakes, Frittata" tag already shown on each list item. This completes the "In, and out." framing.
  • Scan an aisle sign → section (OCR/vision, no migration): POST /:storeId/scan-section {image_base64}scanSectionSign reads a store sign photo via gpt-4o-mini vision → adds the section(s) in walk order (an end-cap listing several categories adds them all; dedups). The store-walker's tool — cataloging a store (the §10 gig deliverable / a shared catalog) becomes snapping each aisle marker front-to-back instead of typing. Pure golden-tested parseSectionScan. Dashboard Store-layout tab: "📷 Scan sign". Rides the 15mb /api/aisleask body limit.
    • Glasses capture path (Mentra Live, hands-free): say "scan this aisle" → the glasses session.camera.requestPhoto() the sign, POST it to the same /scan-section endpoint, and JQ confirms in-ear ("Added Dairy and Bakery to your store map."). This is the crosstalk-mesh #2 "glasses ARE the cataloging device" build the QuickSites session graded as the real near-term one: a glasses-sourced walk-order feeds the exact ordered-section catalog the AisleAsk odd-jobs gig accepts. Client: scanAisleSign; intent aisle/scan (intent.ts, explicit "scan/catalog this aisle/sign" commands only — never inferred, so a passing sentence can't fire the camera); capture + spoken confirm: scanAisleSection + pure buildScanAck (session.ts). First camera-IN slice in the glasses app (was speak-only). Needs a camera-capable device + backend OPENAI_API_KEY; degrades to a spoken explanation on any failure. Targets the wearer's DEFAULT store (scanAisleSection reads getOrCreateStores()[0], now ordered default-first) — so on a multi-store walk, mark the store you're in "active for glasses" on the dashboard switcher (POST /:storeId/set-default) and every "scan this aisle" lands there. No glasses-side store-binding + no env flag (the aisle/scan command is ungated).
    • Glasses recipe capture (voice → multi-source): say "scan this recipe" → the glasses photograph a physical recipe card and POST it to the one-pass /import-sources endpoint as a single recipe, so its ingredients merge into the shopping list (dups tagged, not dropped) and JQ confirms in-ear ("Added 6 items from Pancakes — you already had 2."). Client: scanRecipeToList; intent aisle/recipe (explicit "scan/add/import this recipe" only, checked before the aisle-sign scan); capture + spoken confirm: scanRecipeToShoppingList + pure buildRecipeScanAck (session.ts). Turns "cook from a card" into hands-free list-building — the glasses feed the same merge engine the dashboard "Combine" panel uses.
    • Glasses list recap (voice, read-only): say "what do I still need" / "what's left" / "read me my list" → JQ reads the remaining list back grouped by section in walk order ("You still need 3 things. In Produce, Apples and Bananas; in Dairy, Milk."). Reuses the existing GET /:store/route (checked items already drop out server-side) via new client fetchAisleRoute; intent aisle/recap (checked before next/got so it recaps instead of advancing); pure buildListRecap + joinSpoken (session.ts, long lists >15 summarize by section count, not every name). Read-only — no camera, works on any device.
    • Glasses add-item (voice): say "add butter to my list" / "put eggs on the list" / "add almond milk" → POSTs one item to the list (backend auto-categorizes by section), JQ confirms ("Added butter to your list."). Client addAisleItem; pure parseAddItem (intent.ts) — explicit "add/put <X>" command only (guards the recipe/aisle capture commands + bare pronouns so a passing sentence can't create items); handler addSpokenItem (session.ts). Completes the hands-free AisleAsk verb set: add · next · got-it · recap · scan aisle · scan recipe.
  • Shared store catalogs (the crowd-catalog payoff; migration 505 aisleask_catalogs + aisleask_stores.catalog_id): one person walks a store (or the §10 paid store-walker) and publishes its ordered section names as a shareable catalog; everyone else shopping there ADOPTS it in one tap instead of hand-building an empty store. POST /:storeId/publish-catalog {name,location} (snapshots the store's sections), GET /catalogs?q= (search by name/location, popularity-ranked), POST /:storeId/adopt-catalog {catalog_id} (copies the walk order in via the shared applyOrderedSections — upsert-by-name so item→section mappings survive; bumps adopt_count + links catalog_id). applyOrderedSections + pure cleanSectionNames (golden-tested) are now shared by the walker-gig ingest AND adopt. Dashboard Store-layout tab: search + "📍 Near me" + "Use this" + "Publish my layout". Nearby (migration 506 adds lat/lng): publishing optionally stamps the browser geolocation; GET /catalogs/nearby?lat=&lng=&radius_km= bounding-box prefilters then ranks by exact distance via pure golden-tested haversineKm (results carry distance_km). Catalogs hold only store + aisle names + optional coords (no address/PII); backend-authed. Apply migrations 505 + 506.
  • Digital deals — Phase 0 crowd "scan this sale tag" (migration 569 aisleask_deals + widens the captures purpose CHECK to add aisleask_deal): driven by a real user ("Grandma Pat") wanting coupon deals peppered into the route + a "shop the deals" mode. POST /:storeId/scan-deal {image_base64}scanDealTag reads a shelf sale tag via gpt-4o-mini vision (sibling of scanSectionSign) → a crowd deal keyed by normalizeProductName (same key as the list + product→section map), and best-effort files an aisleask_deal capture for provenance. GET /:storeId/dealslistDeals (drops deals past valid_until — honesty: store+time-scoped, source shown). Every deal normalizes to the shared Offer shape { store_scope, product_match, offer, source, valid_thru } agreed with QS's future /api/deals capability, so affiliate (revenue-share, no partnership) and a later paid feed (Flipp — deferred, owner's call) are drop-in sources. Dashboard Store-layout tab: "💸 Deals" block (📷 Scan a deal + captured-deals list). Pure parseDealScan golden-tested. Plan: docs/product/AISLEASK_DEALS.md. Apply migration 569.
  • Public storefront SEO page (migration 571 adds aisleask_stores.storefront_image_url + public_slug (unique) + public_deals_enabled): each store can opt into a public, indexable deals page at /store/[slug] — an SEO surface for "<store> deals near me". Owner uploads a front-of-store photogenerateStorefront runs gpt-image-1 image-edit to repaint it painterly (keeps the real building recognizable), uploads to the aisleask bucket, mints a slug, and flips the page on. A one-tap ✨ Generate (POST /:storeId/storefront/generategenerateStorefrontHero) paints a FULLY-GENERATIVE painterly exterior from the store name/type — no source photo, no Google imagery (ToS-clean). Pulling the photo from Google Places was scoped + declined on Google's Place Photos ToS (derivative/attribution/caching); see crosstalk/contracts/aisleask-places-storefront.md. POST /:storeId/storefront. Public reads (no auth): GET /api/aisleask/public/store/:slug (page) + GET /api/aisleask/public/stores (sitemap). The SSR page carries OG + JSON-LD GroceryStore/Offer and renders live deals; public_deals_enabled toggles via updateStore; slugs feed sitemap.ts. Plan: docs/product/AISLEASK_STORE_SEO.md. Apply migration 571. Every hero is versioned — migration 572 adds storefront_image_history (text[], newest-first, capped 12); each generate/upload writes a distinct Storage object, the dashboard shows a thumbnail row, and POST /:storeId/storefront/select repoints the current version (no regen). Apply migration 572.
  • Spatial Store Map (migration 570 adds aisleask_sections.pos_x/pos_y/kind + aisleask_stores.map_image_url): a 🗺 Map tab on /dashboard/aisleask — drag-drop section tiles onto a painterly store-interior background (paste an image URL). Sections gain a kind (aisle/perimeter/facility) so numbered aisles (green) read distinctly from perimeter departments (amber) + facilities (sky); pointer-drag sets normalized 0..1 coords, batch-saved via POST /:storeId/sections/layout. POST /:storeId/sections/seed-perimeter one-taps the standard non-aisle zones; addSection + the section route take an optional kind; map_image_url flows through updateStore/sanitizeStorePatch. Placed tiles are resizable + rotatable (migration 573 adds pos_w/pos_h/rotation on sections) — tap to select, drag the corner to resize or the ⟳ handle to rotate; saveSectionLayout now patches only the fields each gesture sends. A client-side Background opacity slider dims the painterly background so tiles read. The flat walk-order list is untouched — the map is a spatial VIEW of the same sections. Built blind (pointer-drag feel = on-device tuning). Plan: docs/product/AISLEASK_STORE_MAP.md. Apply migration 570.
  • Store address + geo (migration 510 adds address/lat/lng to aisleask_stores): a store now persists a free-text address + optional precise coordinates, so walker routing is accurate (the crosstalk route-planner, QS #519, takes exact lat,lng and skips geocoding). PATCH /api/aisleask/stores/:storeId {name?,address?,lat?,lng?} (updateStore) — the dashboard's "📍 Store address" editor (type an address + "Use my location" for coords → Save). publishCatalog now defaults the catalog's location/lat/lng from the store's saved address/coords when the caller doesn't pass them, so publishing auto-carries the store's geo onto the shared catalog (which QS's route-planner reads). Pure validCoord (now rejects null/''/undefined explicitly — Number(null) is 0, which would otherwise plant "no coordinate" at null-island (0,0)) + sanitizeStorePatch golden-tested. Store-walker economy trio: this (accurate geo) + the route-planner (#519) + walker payouts (logged as an AisleAsk idea task). Apply migration 510.
  • Recipe import ("one or more recipes, scanned or imported → one efficient route"; migration 504 adds aisleask_items.source): POST /:storeId/import-recipe { url | text | image_base64 }importRecipe extracts shopping ingredients (URL via fetchReadableText; pasted text; photo scan via gpt-4o-mini vision — feature_key: aisleask.recipe_import), normalizes (pure golden-tested normalizeIngredients: strip quantities/bullets, dedup, clamp 60), and adds them to the list auto-categorized + labeled with the recipe title (source), deduped against what's already there. Multiple recipes accumulate into one trip; the route engine walks staples + every recipe in section order. Dashboard List tab: "+ Import a recipe" (paste text / link, or 📷 scan a photo); items show their · recipe source. Body limit bumped to 15mb for /api/aisleask (photo data-URIs). Apply migration 504.
  • Glasses turn-by-turn hook: buildNextLine(route) (pure, golden-tested) → the spoken line ("Next, get Bread — it's in Bakery. 2 more after this."); section-level guidance for v0, NOT spatial nav (needs vision the glasses don't have). GET /:storeId/next{ done, item_id, item_name, section_name, remaining, spoken }. Glasses side (apps/glasses/src/): fetchAisleNext/fetchAisleStores/checkAisleItem in hivejournal.ts, speakAisleNext(session, checkFirst, player) in session.ts (plain TTS like a lever ack, ducks under the soundtrack; "got it" checks the last item off + advances), and the aisle intent (next / got_it) in intent.ts (shopping-scoped, checked before Sophia so "what's next on my list" isn't swallowed as a nudge). Backend fully tested; glasses layer built-blind-tune-on-device per the app's convention (27 node:test cases incl. aisle routing).
  • Pick-session instrumentation → the DoorDash proof metric (migration 564 aisleask_pick_sessions + aisleask_pick_events, RLS service-role-only, human-provisioned): the timed shopping-trip layer that produces the number the whole AisleAsk → DoorDash pathway rests on — pick time / pick rate / accuracy / substitution rate. Before this, items only had a checked_at; there was no timed trip. A session is started (snapshotting the unchecked item_count), each pick decision is logged as an event (picked / substituted / unavailable, optional off-list item_id + substitute_name), and on complete the trip's metrics are computed. Pure metrics core (golden-tested — a quiet regression corrupts the pilot's headline number): aisleask-metrics.ts computePickMetrics(session, events){ duration_seconds, items_picked, substituted, unavailable, accuracy_pct, substitution_rate_pct, items_per_hour, completed }. Formulas: accuracy_pct = picked / (picked+substituted+unavailable) × 100 (0 when no events — no divide-by-zero), substitution_rate_pct = substituted / (…) × 100, items_per_hour = picked ÷ (duration_seconds/3600) guarded to 0 when duration_seconds ≤ 0 (never Infinity/NaN), duration_seconds = round((completed_at − started_at)/1000) or 0 when incomplete/malformed/reversed. PURE — never calls Date.now() (a caller wanting a running duration passes a synthetic completed_at), which is what makes the pilot number deterministic + testable. Tests: aisleask-metrics.test.ts (7 cases: normal trip, rounding, zero-duration guard, empty, all-substituted, incomplete, malformed). Service: aisleask-sessions.tsstartPickSession (reuses assertOwnedStore), recordPickEvent (verifies session is caller's + open; a picked on-list item is also checked off the list), completePickSession (stamps completed_at, returns session + metrics), listPickSessions, pickSessionStats (the pilot dashboard number — avg duration / items-per-hour / accuracy / substitution over COMPLETED trips, filterable by store + source). Routes (all authenticateUser, under /api/aisleask): POST /:storeId/pick-sessions (start), POST /pick-sessions/:id/events (record), POST /pick-sessions/:id/complete, GET /pick-sessions?storeId=&limit= (list w/ metrics), GET /pick-sessions/stats?storeId=&source= (aggregate). sourceself | doordash | pilot. Apply migration 564. Shopper frontend: /shop (below).
  • Shopper experience (the picking flow)/shop (ShopClient.tsx): the phone-first surface a shopper actually uses to pick an order fast — the picking half of AisleAsk (the dashboard at /dashboard/aisleask is the list-management half). Standalone-feeling (own minimal header, not DashboardLayout), reuses apiRequest + the existing /api/aisleask/* shapes + the pick-session endpoints above. A small state machine: loading → store-select (GET /stores; one store auto-selects) → list (route grouped by section via GET /:storeId/route; quick-add POST /:storeId/items, paste a whole list via POST /:storeId/import-sources {recipes:[],lists:[text]}; "Start shopping" CTA) → picking (POST /:storeId/pick-sessions {source:'self'} snapshots the trip, then walks the route one item at a time with three big touch targets — Got it picked / Sub substituted+substitute_name / Out unavailable — each firing POST /pick-sessions/:id/events; a live elapsed timer (setInterval cleared on unmount) + progress n / total; optimistic advance, actions disabled in-flight) → done (POST /pick-sessions/:id/complete → a summary card: big pick time, items/hr, accuracy %, subs; "Shop again" resets to the list). The public demand-side marketing page is /for-shoppers. No migration (frontend only).
  • Multi-customer batch picking (the order dimension) (migration 565 aisleask_orders + nullable aisleask_items.order_id, RLS service-role-only, human-provisioned): DoorDash shoppers pick 2–3 customers' orders in one merged trip, bagged per customer. STRICTLY ADDITIVE — order_id is nullable (null = the shopper's generic single-customer list, unchanged); items flow through the same orderItemsByRoute engine (one merged walk, order_id rides along for per-customer attribution). Service aisleask-orders.ts: createOrder / listOrders / addOrderItems (reuses addItem's section-guess path) / deleteOrder (FK ON DELETE SET NULL → items return to the generic list) / setOrderStatus (active|done|cancelled); per-order metrics pickSessionByOrder(userId, sessionId) groups a trip's pick events by each event's item→order and reuses computePickMetrics per order + a batch total. PURE core (golden-tested aisleask-orders.test.ts): groupEventsByOrder(events, itemToOrder) + rollupByOrder(session, events, itemToOrder, orderLabels) (real orders by label, generic bucket last; zero-event orders show all-zeros over the shared trip clock). Routes (authenticateUser, /api/aisleask): POST /:storeId/orders, GET /:storeId/orders, POST /orders/:orderId/items {names[]}, PATCH /orders/:orderId {status}, DELETE /orders/:orderId, GET /pick-sessions/:id/by-order. Existing item SELECTs are select('*'), so order_id surfaces on /items + /route additively. Apply migration 565.
    • Batch shopping UI/shop gains a "🧑‍🤝‍🧑 Shopping for multiple customers?" mode: add customers (orders) + their items in the list phase, pick the merged route with same-name items grouped per customer (Milk ×3 — Alice ×2 · Bob ×1; one-tap ✓ Got all N or per-customer ✓/↔/✕), a per-customer bagging step (DoorDash bags per order), and a summary with batch throughput (orders/hr, items/hr, accuracy) + a per-customer breakdown from GET /pick-sessions/:id/by-order. Single-customer mode is byte-for-byte unchanged (a separate picking && tripBatch branch; zero orders → the original flat flow).
  • Shared product→section resolver (migration 566 aisleask_product_sections, global — not per-user/store — RLS service-role-only, human-provisioned): item→section categorization is computed once for everyone and cached, cutting LLM calls + building a moat. aisleask-product-sections.ts resolveSection(name) = ① shared-map lookup (free, bumps hit_count) → ② keyword guessSection (hit → upsert keyword) → ③ LLM (complete gpt-4o-mini, temp 0, constrained to STANDARD_GROCERY_SECTIONS + validated back → upsert llm) — tier ③ enabled, fires only on a true ①②-miss for a cacheable name, then a free tier-① hit forever. recordSectionCorrection (crowd reinforcement — a shopper's section-move feeds the map) + a PURE golden-tested normalizeProductName. Wired into aisleask.ts addItem (byte-for-byte identical when keyword already answered; unknowns that were "Unsorted" now get a cached section) + updateItem (best-effort correction). Canonical section is global; the per-store aisleask_sections still provide the walk order. Apply migration 566.
  • Delivery-leg routing (reuse, not reinvent) (migration 567 adds nullable customer_address/customer_lat/customer_lng to aisleask_orders): the driver's drop route for a multi-customer batch — consumes QuickSites' live route-optimize (PorchHearth's nearest-neighbor+Haversine engine; contract crosstalk/contracts/route-optimize.md), NOT our own routing. In-store section routing stays AisleAsk's orderItemsByRoute; only the geographic leg reuses QS. aisleask-delivery.ts: pure buildRouteRequest (store→start, orders→stops — lat/lng passed through to skip geocode, else {label,address}, else skipped; ≤20-stop cap → overflow to dropped); planDeliveryRoute fetches QS_ROUTE_OPTIMIZE_URL (default https://www.quicksites.ai/api/tools/route-optimize) with a 10s AbortController timeout; non-200/abort/transport → clean errors, never a crash. Returns {ordered (nearest-first), maps_url (the tap-ready Google Maps driver deep-link), total_miles (crow-flies — NEVER surfaced as driving miles), unresolved, skipped, dropped}. Route: POST /:storeId/delivery-route. Frontend (batch mode): a per-customer 🏠 drop-address input + a 🚗 Plan delivery route button in the bagging step (≥2 located customers) → numbered drop order + "Open in Maps". Golden-tested aisleask-delivery.test.ts. Apply migration 567.

Downstream (the "stress curves" app)

"Decide with the version of you who has to live with it." Models a deferred/maintenance thing not as a due date but as a curve — how its cost + stress grow over time — and triages your list by where each thing is on its curve, leading with relief ("let these drift; do this one"). The maintenance half of the self (DreamPro is the aspiration half). v1 built 2026-07-15. Full product doc: docs/product/DOWNSTREAM.md.

  • Curve model (deterministic core): apps/backend/src/services/downstream-curves.ts — 5 archetypes (flat / ramp / decay-logistic / step / compounding), pressureAt / snapshot (with +7d/+30d forecast + honest snooze cost), phaseFor, normalizeCurve (safe-to-store fallbacks), and mirrorVerdict (the cost-vs-stress gap → just_do_it / warn / do_now / let_it_drift). Tested by downstream-curves.test.ts (8/8).
  • Service: apps/backend/src/services/downstream.tsinferCurve (LLM gpt-4o-mini turns a plain-language "thing I'm putting off" into a curve + cost/stress levels + downstream summary), item CRUD, and getTriage (open items + live snapshots + relief summary).
  • Route: apps/backend/src/routes/downstream.ts at /api/downstreamPOST /infer, POST /, GET /triage, PATCH /:id, POST /:id/{resolve,snooze,dismiss}. Registered in index.ts.
  • Migration: supabase/migrations/481_downstream_items.sqldownstream_items (archetype + params + anchor + cost/stress levels + status + resolution_feedback calibration signal). RLS on, backend-only.
  • Frontend: apps/frontend/src/app/dashboard/downstream/page.tsx — capture (type a thing → inferred-curve preview → add), a relief-first triage list with a per-item pressure gauge + phase/verdict chips, resolve-with-calibration ("how bad was it — easier/as expected/worse"), and snooze-with-consequences.
  • Curve-timed nudges (the killer feature): apps/backend/src/services/downstream-nudge.ts runDownstreamNudgeTick — a 6-hourly cron finds each user's most-pressing knee-crossed item, composes a calm templated line, renders it in the user's own cloned voice (sayInUserVoice, cache-first) when available, and records it. ≤1/user/tick, ≥3 days/item. OFF by default (toggle downstream_nudge, heartbeat downstream_nudge_tick; wired in index.ts + cron-toggles.ts + system-health.ts). Endpoints GET /api/downstream/nudges + POST /nudges/:id/seen; surfaced as a banner on the dashboard. Migration 482_downstream_nudges.sql — the nudges table doubles as the glasses traceability log.
  • Quiet hours + traceability: downstream_prefs (migration 483) stores the browser-captured timezone + quiet window; the nudge tick skips a user's night (default 21:00–08:00 local). GET/POST /api/downstream/prefs. The dashboard shows a "what Downstream has told you" history (the /nudges log). Public landing page at apps/frontend/src/app/downstream/page.tsx (/downstream) — linked from the Features catalog + site footer.
  • Built-in Pomodoro (focus sessions, v0): apps/backend/src/services/downstream-focus.ts — turns triage's "do this one" into a bounded work/break session (classic 25/5, long break every 4 rounds). Pure core (nextPomodoroPhase state machine, phaseMinutes, composeFocusCue) tested by downstream-focus.test.ts (16/16). At each boundary it speaks a calm cue in the user's own voice (reuses sayInUserVoice + sendToUser push) and logs it into the downstream_nudges traceability log (phase focus_*, marked seen so it doesn't pop as a curve-nudge banner). The Downstream-specific twist: finishing a work phase on a pinned item EASES that item's curve — applyFocusProgress (downstream.ts) nudges the anchor forward via focusEasedAnchor (downstream-curves.ts, elapsed-based archetypes only; ramp/flat untouched), so pressure decays. Endpoints: GET /focus/active, POST /focus/start, POST /focus/:id/{advance,pause,resume,complete,abandon} (registered before the generic /:id routes). Migration 485_downstream_focus_sessions.sql (downstream_focus_sessions, one-active-per-user partial unique index). Frontend: a focus widget (live countdown, pause/skip/done, in-voice boundary cue) + a per-item Focus ▶ button on the dashboard. The client timer drives boundaries while the app is open; app-closed delivery is the downstream_focus cron (runDownstreamFocusTick, 1-min cadence, OFF by default) — it advances due sessions so the in-voice + push cue lands with the tab closed. Both are safe together via a compare-and-set in advanceSession (only the first caller on a boundary wins — no double cue), and a session self-terminates after its long break (completes the set) so an absent user is never cycled forever. Wired in index.ts + cron-toggles.ts (downstream_focus) + system-health.ts (downstream_focus_tick). Sketch: DOWNSTREAM_AUDIO_IDEAS.md §2.
  • Ultradian-rhythm engine (v0, the timing layer): apps/backend/src/services/downstream-rhythm.ts — estimates where you are on your ~90-min BRAC energy wave from time-since-wake (no hardware): estimatePhase returns a phase (pre_wake/rising/peak/falling/trough), a modeled energy (sine — trough at cycle boundaries, peak mid-cycle), and a deliberately-modest confidence that decays across the day; phaseSuggestion maps the phase to a calm suggestion (peak → take the hard item; trough → take the break). Pure module, tested by downstream-rhythm.test.ts (9/9). getRhythm (downstream.ts) reads the user's tz + wake time to compute it; GET /api/downstream/rhythm. Wake time + cycle length live on downstream_prefs (migration 486: wake_minutes_local, ultradian_cycle_min). Frontend: a compact rhythm strip (phase + energy bar + suggestion + a settable wake time, labeled "rough estimate") on the dashboard. The rhythm engine drives the Pomodoro cadencerhythmTunedDurations(phase, base) (pure, tested) adjusts a session's block/break lengths to the current phase (peak → longer block to ride it; trough → shorter block + longer break; ramp/flat-of-the-day untouched), applied in startSession when the client requests it via rhythmTune (a "⚡ Match focus blocks to my energy" toggle on the dashboard, on by default). v1 — signal calibration: calibratePhase(base, taps) (pure, tested) blends recent signal into the heuristic as a recency×weight-weighted average, re-labels the phase from the blended energy, raises confidence, and flips source to behavioral. Two signal sources: self-report Low/Steady/High taps (recordEnergyTap + POST /api/downstream/energy { level: 1..5 }, stored in downstream_energy_taps, migration 487, weight 1.0) and behavioralfocusEnergyTaps derives an energy signal from recent completed focus sessions (sustained focus → energy; more rounds = stronger; weight 0.5). getRhythm merges both, plus an optional interaction-cadence signal — interactionEnergy(eventsPerMinute) (pure, tested) maps how actively you're using the app right now to a weak, positive-only nudge (0.4–0.7, weight 0.3); the client sends live events/min via GET /api/downstream/rhythm?epm=N and it's ephemeral — never stored (so it stays out of the open-science data). Dashboard: Low/Steady/High tap buttons + a "calibrated" badge. The tap table is also the substrate for the future open-science "shape of the day." The phase also drives a concrete pickrecommendForPhase(candidates, phase) (pure, tested) chooses an item from the open list for the moment (hardest at peak/rising, easiest at falling, easiest-or-rest at trough), folded into the getTriage response as pick + phase and shown as a "Right now → <item>" line (with Focus ▶) in the rhythm strip. Open-science overlayaggregateEnergyCurve (pure, tested) buckets energy taps into an average-energy-by-hour curve; getEnergyCurves returns the user's personal curve + the anonymized normative "shape of the day" pooled across everyone (masking any hour with < 5 taps — the anonymity floor). GET /api/downstream/rhythm/curve; a collapsible "shape of your day" SVG overlay (you vs everyone) on the dashboard. v0 aggregates in-process over a bounded window; at scale → a precomputed materialized curve on a cron. Sketch: DOWNSTREAM_AUDIO_IDEAS.md §3. v2 = opt-in watch HR/HRV.
  • Mantras (migration 512 downstream_mantras): your chosen mantras — and inspirational quotes — spoken back in your own cloned voice. Two kinds per row: mantra (your words) + quote (someone else's, with author). Rendered via sayInUserVoice (jq-voice.ts → the jq_voice_clips cache, so identical (user, voice, text) is never re-synthesized) — self-consumed private motivation, so rendering a quote in your own voice is fine (attribution shown). Pure core downstream-mantras-core.ts: normalizeMantra (≤280) + weavePlaylist (peppers a quote after every N mantras, cycling, leftovers appended — golden-tested, 5 cases). Service downstream-mantras.ts: CRUD + getMantraPlaylist (active rows → weave → render each in-voice; optional daily=1 folds in the rotating ZenQuotes quote via getDailyQuote; throws MantraVoiceError → route 409 {state} when the clone isn't ready so the UI prompts voice setup). Routes under /api/downstream/mantras (GET/POST, GET /playlist, PATCH/DELETE /:id, registered before the generic /:id). Dashboard: MantrasPanel.tsx — add mantra/quote, reorder, toggle active, ▶ Play all (chained in-voice sequence with quotes peppered in), "weave in today's quote" checkbox. Product surface (#1695–#1698): Mantras is a first-class product, not just a Downstream panel. Public page /mantras (server, scoped OG, painterly hero backdrop public/painterly/mantras-candle-dawn.webp). Dashboard host /dashboard/mantras (client) composes the panel + two extras from mantra-content.ts: starter sets (MantraSets.tsx — 7 themed packs bulk-adopted via repeated POST /api/downstream/mantras, panel reloads via a reloadSignal prop) and an inspiration bookshelf (MantraShelf.tsx — 12 curated titles, Open Library covers, Amazon search affiliate links w/ hivejournal-20). Surfaced in Navbar "More", SiteFooter, the shared nav-catalog, and the /features catalog. Glasses: say "play my mantras"fetchMantras pulls the in-voice playlist and playMantras (session.ts) plays each real voice clip in-ear in sequence (ducks under a soundtrack; 409 → speaks a voice-setup line; "no mantras" hint points at /dashboard/mantras); intent mantras (intent.ts, explicit "mantras" keyword). Voice source (Phase 1 of MANTRAS_COACH_VOICE.md, migration 601): the playlist can render in the user's own clone OR a stock VOICE_BANK presetgetMantraPlaylist resolves the stored pref (downstream_prefs.mantra_voice_source/mantra_voice_id) and dispatches own→sayInUserVoice, stock→sayInVoiceId. The stock path is whitelist-only (setMantraVoicePref/isStockVoiceId reject any non-VOICE_BANK id) so a real person's clone can never land here — that's gated Phase 2. Stock also unlocks Play-all for users with no clone (no 409). Picker on MantrasPanel; endpoints GET/PUT /api/downstream/mantras/voice. Glasses honor the stored pref with no glasses change. Apply migrations 512 + 601.
  • In-ear soundtracks (v0, the focus/mood bed): the looping soundtrack layer on Downstream Glasses, ducked under JQ. Handoff/spec: DOWNSTREAM_SOUNDTRACKS_HANDOFF.md + DOWNSTREAM_AUDIO_IDEAS.md §1. Reuses the studio Suno ingestionimportFromUrl/storeUpload in creator-audio-library.ts scrape a Suno share page and re-host the MP3 (surviving CDN rotation); the soundtracks layer keeps only a thin reference row. Pure core downstream-soundtracks-core.tsmoodForPhase (the ultradian bridge: peak→energize, rising→focus, falling/trough→calm, pre_wake→ambient) + pickForMood (exact-mood match → first loopable → first; honest fallback chain), tested by downstream-soundtracks-core.test.ts (10/10). Service downstream-soundtracks.ts — list (owned + curated), import/upload, update, archive, and getSoundtrackForPhase (reads getRhythm → mood → pick). Endpoints under /api/downstream/soundtracks: GET /, GET /for-phase, POST /import, POST /upload (multipart), PATCH /:id, DELETE /:id (registered before the generic /:id routes). Migration 489_downstream_soundtracks.sqldownstream_soundtracks (nullable user_id = curated/global, mood CHECK, loopable, re-hosted audio_url). RLS on, backend-only. Glasses looper apps/glasses/src/soundtrack.tscreateSoundtrackPlayer(session) loops session.audio.playAudio({ audioUrl, volume: 0.22 }), re-fetching each loop so the pick stays phase-adaptive, and ducks under every nudge via duckFor() (wired into speakNudge in session.ts). nextReplayDelayMs (pure, tested) makes the loop correct whether playAudio resolves at track-end OR immediately. Voice control: "play/stop music" via the keyword floor in intent.ts; auto-start behind SOUNDTRACK_ENABLED (default off). Frontend: a collapsible "🎧 Soundtracks" panel on the dashboard (import a Suno link + mood, upload an MP3, list owned/curated beds, inline per-track mood editing + a mood filter, and an in-browser preview of the current phase pick). Curated pool admin (super-admin): admin-soundtracks.ts at /api/admin/soundtracks (list/import/upload/patch/delete curated beds — user_id NULL, importCuratedSoundtrack/uploadCuratedSoundtrack/etc. in the service; the re-hosted asset is owned by the admin, the Downstream row is global) with a page at /dashboard/admin/soundtracks. On-device unknown left to verify (Mentra Live): does a nudge's playAudio MIX or INTERRUPT the bed (decides whether duckFor's best-effort stopAudio is needed) — the looper is built to be correct either way. Licensing gate: Suno commercial rights are plan-dependent — fine for personal/dev; tick the box before anything public.
  • Recurring items (maintenance that repeats): downstream_items.recurrence_days (migration 490, nullable, 1–3650) makes an item a repeating obligation. On resolve, resolveItem still marks THIS instance done (so it lands in history + calibration), then clones a fresh open item anchored at now — pressure resets and climbs toward the next cycle. Set via recurrenceDays on create/update; UI is a "🔁 Repeats" chip row in capture + the edit form, and a "🔁 weekly/monthly/…" badge on recurring cards.
  • Dashboard read/edit surfaces (all on /dashboard/downstream): an insight hero (today's act/drift/trap counts + the "dread vs. reality" mirror — GET /api/downstream/calibration, aggregation in the pure downstream-calibration-core.ts shapeCalibration, tested 7/7, surfacing your easier-rate + the category you most over-worry); bucketed triage (Act / Quiet-traps / Drift shelves + category filter + pressure sort); inline item editing (updateItem now takes anchorDaysAgo to re-anchor the curve; edit title/category/cost/dread/how-long) with a 3d/1w/1mo snooze menu; and a Completed history (GET /api/downstream/historylistResolved, each with its easier/as-expected/worse outcome). PRs #1279/#1280.
  • Not yet built (phasing in the doc): mood playlists + shared/synced group soundtrack (§1 v2, room-scoped via the Bridge graph), normative/open-science crowd curves (citizen-science), and household/shared curves.

Levers (Intervention ROI)

"Track a level of something, pull a lever, and see what moved." The return-on-action sibling of Downstream (stress curves = cost of inaction; Levers = return on action). Concept: docs/product/INTERVENTION_ROI.md. v0 built 2026-07-15.

  • Deterministic core: apps/backend/src/services/levers-core.tsinterventionEffect(readings, atMs, windowMs) measures the before/after delta (baseline = latest reading at/before the action; after = earliest within a 6h window; null when either side is missing), predictionError(predicted, actual), and summarizeLevers(interventions) which rolls repeated pulls of the same lever into an average effect (grouped by normalized name, measured pulls only, ranked by impact — this ranking is the "action X vs. Z" comparison, and averaging is the honest signal since one before/after is noise). Tested by levers-core.test.ts (12/12).
  • Service: apps/backend/src/services/levers.ts — CRUD for levels/readings/interventions + getOverview (every level with its readings, and each intervention paired with its measured effect + prediction error).
  • Route: apps/backend/src/routes/levers.ts at /api/leversGET /, POST /levels, POST /levels/:id/readings, POST /interventions. Registered in index.ts. POST /interventions also accepts targetLevelName (a SPOKEN level name — the glasses voice path): resolved via matchLevelByName in levers-core (exact → prefix → bidirectional substring), defaulting to the user's only active level; the response includes target_level_name for the spoken ack.
  • Voice logging (Downstream Glasses): say "log a 20-minute walk for energy"apps/glasses/src/intent.ts parseLeverUtterance (explicit log …/lever … command prefixes ONLY — free speech never logs; checked before Sophia so "log that I finished my walk" can't be swallowed as a step_complete nudge) → logLever in hivejournal.tsspeakLeverLog in session.ts acks in-ear via plain TTS (a system receipt, not a JQ voice render), ducking under a running soundtrack.
  • Migration: supabase/migrations/488_levers.sqllevers_levels / levers_readings / levers_interventions. RLS on, backend-only.
  • Frontend: apps/frontend/src/app/dashboard/levers/page.tsx (/dashboard/levers) — create a level (name + scale), log readings on a slider (with a sparkline + intervention markers), log an intervention with an optional predicted Δ, a "What moves <level>" ranked card (averaged effect per lever with a bar + calibration "±N vs guess" + "again:" chips to reuse a lever name so repeats group), and a per-intervention ROI list showing actual delta vs. your prediction ("nailed it" / "off by N").
  • Honest v0: before/after correlation (not causal proof — the UI says so); the prediction-vs-reality loop is the overclaim-proof wedge; the averaged "what moves this" ranking is the per-user X-vs-Z comparison. Not yet built: cross-window counterfactual matching (X vs Z vs nothing), between-people/normative dose–response, wearable/lab import.

Analytics (GA, FB Pixel)

Frontend tracking integration.

  • Frontend: components/analytics/GoogleAnalytics.tsx · FacebookPixel.tsx
  • Reference: GOOGLE_ANALYTICS_SETUP.md · META_PIXEL_SETUP.md
  • Hostname-gated Facebook Pixel (PR #509). The same Vercel deployment serves both hivejournal.com AND graphene.fm. Pre-#509 the FB Pixel fired on every host that had NEXT_PUBLIC_FB_PIXEL_ID set, mixing two distinct audiences (journaling humans + audio-fiction listeners) into one cohort and degrading Meta's lookalike-audience suggestions for both surfaces. New optional NEXT_PUBLIC_FB_PIXEL_HOSTNAME env var: when set, the pixel only fires when window.location.hostname.endsWith(<value>). Unset preserves pre-#509 global behavior. Set to graphene.fm for a Graphene-only pixel; set to hivejournal.com for a HiveJournal-only pixel (or run two pixels by hosting the component twice — not currently wired but ~10 lines).
  • Conversions API (CAPI) status: NOT YET WIRED. Meta's CAPI is the server-side complement to the browser pixel (catches ~30-40% of conversions missed by ad-blockers + iOS 14.5+ ATT opt-outs). Decision: defer until a Sales-objective campaign exists with conversion volume to optimize against (current Graphene+ trajectory per funnel readout #489 is 1 sub / 28d — pre-CAPI threshold). When wired, fire from existing Stripe webhook + email-confirm route + /seasons/[id] GET; mirror the recordHeartbeat pattern so delivery failures land in service_errors. Setup guide has the full template.

Authentication & Identity

Branded Auth Emails (Supabase Send Email Hook)

Supabase auth emails (signup confirmation, password reset, magic link, email change) are sent via a custom HTTP hook so each surface gets its own branded sender + template. The hook detects the originating brand from user_metadata.brand (set by the frontend at signup time from the host), renders the appropriate template, and sends via Resend.

  • Route: POST /api/auth/email-hookapps/backend/src/routes/auth-email-hook.ts. Mounted BEFORE express.json() in index.ts so the raw body is available for Standard Webhooks HMAC verification (using standardwebhooks package).
  • Renderer: apps/backend/src/services/auth-emails/render.ts — switch keyed on ${brand}:${emailActionType}, falls back to a generic HiveJournal-branded template for any combination without a dedicated template (so Supabase never gets a 500 for an unhandled action).
  • Templates: apps/backend/src/services/auth-emails/templates/ — one file per <brand>-<action>.ts. Phase 1: graphene-signup.ts only (Graphene confirmation email). Phase 2: hivejournal + writecafe brands, recovery/magiclink/email_change actions.
  • Frontend signup wiring: apps/frontend/src/app/auth/signup/page.tsx passes options.data.brand derived from detectBrandSlug() (branded-surface.ts) so the hook knows which brand to render.
  • Env vars (per backend env): SUPABASE_AUTH_EMAIL_HOOK_SECRET (Standard Webhooks secret, format v1,whsec_…), RESEND_API_KEY (already set, shared with DreamPro emails), RESEND_FROM_ADDRESS_GRAPHENE (optional, defaults to Graphene <noreply@graphene.fm>).
  • Supabase dashboard config: Authentication → Hooks → Send Email Hook → HTTPS → URL: https://hivejournalbackend-{production,staging}.up.railway.app/api/auth/email-hook → paste the secret.
  • Resend prerequisite: graphene.fm must be added as a verified sender domain at Resend (DNS records on the brand's domain). hivejournal.com is already verified and used as the fallback sender for any (brand, action) combination without a dedicated template.

Graphene+ Trial-to-Paid Email Drip

4-email nurture sequence fired by a daily cron, targeting Graphene signups who haven't subscribed to Graphene+ yet. Day 2 (welcome + show recommendation), Day 5 (listener-letters wedge explainer), Day 7 (paywall preview), Day 14 (last-call). Default-on for users with user_metadata.brand === 'graphene'; per-email one-click unsubscribe via List-Unsubscribe + List-Unsubscribe-Post: One-Click headers (RFC 8058, the gold-standard signal for Gmail/Yahoo deliverability scoring).

  • Service: apps/backend/src/services/graphene-plus-drip.ts — all 4 templates inlined + runGraphenePlusDripTick() cron entry point + HMAC-signed mintUnsubToken / verifyUnsubToken helpers (token = userId || sha256(userId + secret)[:8], base64url-encoded).
  • Cron: 1h interval, registered in index.ts parallel to the daily-prompt cron. Heartbeat name graphene_plus_drip_tick. Window tolerance: ±18h around each step day so cron-down windows don't miss users; the (user_id, step) unique constraint on the log table prevents double-sends.
  • Unsubscribe route: GET/POST /api/graphene-plus-drip/unsubscribe?token=…apps/backend/src/routes/graphene-plus-drip.ts. GET renders a confirmation page; POST handles the Gmail one-click flow.
  • Migration: supabase/migrations/268_graphene_plus_drip.sql — creates graphene_plus_drip_log (user_id, step, sent_at) table with unique (user_id, step) constraint for idempotency, plus profiles.graphene_plus_drip_opted_out_at opt-out column.
  • Gates per step: (1) brand === 'graphene', (2) no active graphene_plus subscription, (3) graphene_plus_drip_opted_out_at IS NULL, (4) no row in graphene_plus_drip_log for this step. Anything failing a gate is skipped silently and counted in the tick result for the heartbeat metadata.
  • Env vars: GRAPHENE_PLUS_DRIP_UNSUB_SECRET (HMAC key — falls back to SUPABASE_SERVICE_ROLE_KEY if unset). RESEND_FROM_ADDRESS_GRAPHENE (same as Branded Auth Emails). PUBLIC_BACKEND_URL for building unsubscribe URLs that point at the backend host.

Lovio Waitlist Nurture Drip

4-email pre-launch nurture sequence (Day 0/7/21/45) for lovio_waitlist signups. Founder-voice, signed by Sandon. Day 0 welcome, Day 7 "what would you write" soft poll (replies become marketing copy gold), Day 21 voice-ethics essay (trust-building), Day 45 founder-invite-list opt-in via reply-to-confirm. Default-on for any waitlist row where unsubscribed_at IS NULL; per-email one-click unsubscribe via RFC 8058 headers.

  • Service: apps/backend/src/services/lovio-waitlist-drip.ts — all 4 templates inlined with rose-gradient brand chrome matching the lovio-signup auth email template. runLovioWaitlistDripTick() cron entry point + HMAC-signed unsub tokens.
  • Cron: 1h interval, registered in index.ts parallel to the graphene-plus drip. Day 0 uses tighter window (±90 min) so the welcome lands within ~1h of signup; Day 7/21/45 use ±18h for cron-down resilience. Heartbeat name lovio_waitlist_drip_tick.
  • Unsubscribe route: GET/POST /api/lovio-waitlist-drip/unsubscribe?token=…apps/backend/src/routes/lovio-waitlist-drip.ts. Flips lovio_waitlist.unsubscribed_at (reuses the existing column from migration 213 — no separate opt-out column needed since waitlist signup ≡ email-channel consent).
  • Migration: supabase/migrations/269_lovio_waitlist_drip.sql — creates lovio_waitlist_drip_log (waitlist_id, step, sent_at) with unique (waitlist_id, step) for idempotency.
  • Env vars: LOVIO_WAITLIST_DRIP_UNSUB_SECRET (HMAC key — falls back to SUPABASE_SERVICE_ROLE_KEY if unset). RESEND_FROM_ADDRESS_LOVIO (same as Branded Auth Emails). PUBLIC_BACKEND_URL for building unsubscribe URLs. LOVIO_REPLY_TO (optional — defaults to sandon@lovio.io; Day 7 + Day 45 are reply-to-the-founder asks).

Drip Email Preview (super-admin)

/dashboard/admin/drip-preview renders the full HTML + plain-text + subject for every step of every drip in the system so the operator can eyeball them before they hit any real inbox. Tabbed by drip (Graphene+ trial-to-paid, Lovio waitlist nurture). Each step has a "Send to me" button that fires the actual email to the calling admin's address with a [PREVIEW Day N] subject prefix — useful for Gmail / Outlook / Apple Mail rendering checks the iframe can't reproduce.

  • Backend route per drip: GET /api/{drip-route}/admin/preview returns {drip, accent, previews[]} with each preview {step, subject, html, text, from}. Super-admin gated.
  • Send-test route per drip: POST /api/{drip-route}/admin/send-test/:step sends the rendered email to req.user.email. No log row is written (this isn't part of the real drip).
  • Renderer helpers exported from the service files: previewGraphenePlusDripStep(day) + previewLovioWaitlistDripStep(day). Both use sample-context placeholders (preview@example.com, Sample listener) that should never appear in a real sent email.

Admin & Operations

Compare-cluster registry + quarterly freshness cron

Source of truth for which products have (or should have) a competitor "vs" SEO cluster (/compare/<product>, honesty-first), and the machine that keeps them fresh. Design + full audit: COMPARE_CLUSTER_AUDIT.md. Service compare-registry.ts: code-owned COMPARE_REGISTRY (one entry/product: live/candidate/skip + competitors + pricesVerified), a pure auditCompareRegistry() (flags a live cluster stale when its PRICES_VERIFIED is >90d old, a candidate w/o cluster a gap — golden-tested in compare-registry.test.ts), and runCompareRegistryAuditTick() which files a product_tasks refresh task per stale cluster + build task per gap (idempotent; category "Compare clusters" ⚖️). Honesty-first: it prompts a human refresh, never auto-writes competitor claims. Cron compare_registry_audit in index.ts fires daily but works only quarterly (≥90d via a site_settings.compare_registry_last_audit due-check — robust to restarts); OFF by default, kill switch at /dashboard/admin/crons, heartbeat compare_registry_audit_tick. Super-admin on-demand: GET /api/compare-registry/audit, POST /api/compare-registry/run (routes/compare-registry.ts). Pure surface imports supabase dynamically so it's unit-testable without booting the DB. Live clusters today: Family Wall, journaling; top candidate: EmberKiln/Graphene story studio.

Design Partners (superadmin CRM-lite)

Superadmin board for the handful of people the owner is courting as design partners, at /dashboard/admin/design-partners. Sibling of the QuickSites /admin/design-partners page — the SAME partner ids (daniel/ryan/daryle) live on both products so contacts stay aligned, but the pipeline is per-product (a partner can be at a different stage on each side; decided with the QS crosstalk session, v1 — no cross-product sync). Service design-partners.ts: code-owned identity (DEFAULT_PARTNERS — name + honest context + optional hjWarmPath) merged with a mutable per-partner pipeline (status prospect→contacted→engaged→active→paused, next_step, due_date, notes, email/phone/referral_code, warm_page_url, last_nudged_at) persisted as one JSON blob in the shared site_settings key/value table under design_partners_pipelineno migration (same pattern as admin-throttles.ts; 60s cache, bust-on-write). Contact PII lives in the editable pipeline (owner fills in-UI), not in code. Routes (super-admin, inline profiles.role check like cron-toggles): GET /api/design-partners (identity + pipeline per partner), POST /api/design-partners/:id (patch pipeline; markNudged:true stamps last_nudged_at). UI shows a card per partner with an inline "nudge overdue" flag (past-due, not-active) + a "✅ Mark nudged" button. HJ /for-<name> warm pages for these three don't exist yet (future build) — until then a card links out to the QS warm page pasted into warm_page_url.

Experiment Calculators (Computational Companion)

Public, no-auth calculation engine for Open Energy experiments. Users predict expected outcomes (resonant frequency, Q factor, impedance curve, gas production, back-EMF) before building, then compare measured results. "Predict then verify" workflow that cuts wasted physical iterations and surfaces anomalies faster.

  • Calculation engine: apps/backend/src/services/experiment-calculators/ — 6 TypeScript modules (no external deps):
    • resonance.ts — LC tank: f₀, Q, bandwidth, tank voltage/current
    • coil.ts — toroid/solenoid inductance (Wheeler's, Al-value), SRF, distributed capacitance, DC resistance. Includes FT37-43/50/82/114 cores with Al values
    • impedance.ts — Z(f) sweep with magnitude + phase, logarithmic frequency axis
    • electrolysis.ts — Faraday baseline, voltage efficiency, overall efficiency, anomaly flagging when measured gas exceeds Faraday prediction
    • pulsed.ts — back-EMF peak, inductor energy, recovery efficiency, pulse-edge bandwidth, time-domain waveform
    • plasma.ts — Paschen curve for air/argon/nitrogen/hydrogen/helium, breakdown voltage, operating-point analysis
  • Backend endpoints in dreampro.ts: GET /api/dreampro/calculators (catalog + input schemas + options), POST /api/dreampro/calculate (public, rate-limit-ready, body: { calculator, inputs })
  • Frontend component: ExperimentCalculator.tsx — auto-filters calculators by the template's pattern_slug, dynamically generates input forms from the schema, renders results with confidence badges (exact / ~approx / ~order), Chart.js graphs for impedance sweeps + time-domain waveforms + Paschen curves
  • Mounted on research-grade template detail pages (/dreampro/templates/[id]) between the stat strip and the two-column content area
  • Chart.js (chart.js + react-chartjs-2) added as frontend dependency for sweep graphs
  • Phase B — Structured measurements: Migration 078_structured_measurements.sql adds calculator_inputs, calculator_outputs, measured_values, delta_values JSONB to dream_replications + predicted_values JSONB to dreams. PATCH /api/dreampro/replications/:id/measurements accepts measured values + computes predicted-vs-measured delta server-side. "Record your measurements" form in ExperimentCalculator shows real-time delta badges (green <5%, amber <20%, red >20%) as the user types. Comparison is clipboard-copyable into replication notes.
  • Parameter optimization + golden spec + confirm invitations: optimizer.ts runs a full parameter sweep (up to 50K combinations) across all plausible input values for a calculator, ranks by the default objective function (highest Q, highest efficiency, lowest breakdown voltage, etc.), and returns the top N. Endpoints: POST /api/dreampro/optimize (public, runs sweep), GET /api/dreampro/optimize/meta/:calculator (objective + ranges), POST /api/dreampro/templates/:id/golden-spec (super-admin, stores optimal config on the template as predicted_values), GET /api/dreampro/templates/:id/confirm-invitation (public, returns the golden spec + confirmation progress toward 50). Frontend ExperimentOptimizer.tsx renders: "Confirm this prediction" invitation card (when golden spec exists) with recommended build parameters, expected outcomes, convergence bar, copy-share-link + clone CTA; "Find optimal parameters" panel with run-sweep button, ranked results table, admin "Set as golden spec" button per result, sweep summary stats.
  • Phase C — Aggregate results: ExperimentResults.tsx mounted on research-grade template pages. Backend: GET /api/dreampro/templates/:id/measurement-stats aggregates all replications with measured_values — computes per-parameter mean, stddev, min, max, predicted reference, within-5% count, and anomaly detection (>2σ). Frontend renders per-parameter cards: predicted-vs-mean stat strip, convergence bar toward 50 confirmations within ±5% (purple → amber → emerald), Chart.js histogram with predicted-value bin highlighted in green, anomaly count in red. Auto-hides when no structured replications exist.

The Daily Buzz — Single-Panel Comic Strip

A recurring cartoon character reads the user's recent journal entries and generates a single-panel newspaper comic strip with a quippy caption. Multiple character recipes available.

  • Service: apps/backend/src/services/daily-comic.tsgenerateDailyComic() reads last 3 days of entries + satisfaction readings, GPT-4o-mini generates caption + scene description, DALL-E 3 generates the panel.
  • Routes: apps/backend/src/routes/daily-comic.tsGET /api/daily-comic/today (returns all today's comics + selected), POST /api/daily-comic/generate (accepts optional recipe_id + user_id override for admins), GET /api/daily-comic/history (accepts include_ai=true), POST /api/daily-comic/select/:comicId.
  • Comic recipes: Migration 084comic_recipes table. 4 built-in characters: Buzz (default honeybee), SquatBot (gym robot), Void Cat (ominous cat), Professor Snail (patient snail). Users can create custom recipes with character name, visual description, art style, tone, extra instructions. CRUD at /api/daily-comic/recipes.
  • Social/follows: Migration 082buzz_public on profiles, comic_follows table. Users toggle public, others follow. Feed shows followed users' comics. Public profile at /buzz/[userId].
  • Multiple comics per day: selected boolean column, users pick which is "today's comic". Generate another button always available.
  • External embed (QuickSites daily_artifact block) (migration 503): opt-in, off by default, SEPARATE consent from buzz_publicprofiles.buzz_quicksites_optin + an opaque profiles.buzz_embed_token (so third-party sites never get the account id). Public GET /api/daily-comic/embed/:token → the safe subset of the opted-in user's latest comic (image_url/caption/alt_text/date/author), 404 when not (or no longer) opted in — instant revocation. Enable/disable/status: POST /api/daily-comic/embed/{enable,disable} + GET /api/daily-comic/embed (authed); toggle + copyable embed URL on /dashboard/comics (BuzzEmbedToggle). Service in daily-comic.ts. The comic is non-identifiable by design (no real names — stars "Buzz"). Contract: crosstalk/contracts/daily-artifact-embed.md. The consent-gated member of the daily-block family (quote-of-the-day is the zero-consent one). Apply migration 503.
  • Dashboard widget: DailyComicWidget.tsx — shows on dashboard, includes generate/pick/select flow.
  • History page: /dashboard/comics — My Comics / Feed / Discover tabs, recipe selector, localStorage cache.
  • Frontend routes: /dashboard/comics, /buzz/[userId]
  • Migrations: 081 (daily_comics + storage), 082 (comic_follows + buzz_public), 083 (selected column), 084 (comic_recipes + 4 built-ins)

Graphic Novel Reader — Fullscreen Cinematic Experience

Sequential panel-by-panel reader with typewriter text animation for graphic novel stories.

  • Component: apps/frontend/src/components/notebooks/GraphicNovelReader.tsx — fixed fullscreen overlay (z-100), panels fill viewport with object-cover + Ken Burns slow zoom, closed-captioning text at bottom (narration in amber serif, dialogue in white with character names in purple).
  • Features: tap-to-skip typewriter, touch swipe navigation, keyboard controls (arrows/space/esc), auto-hiding UI (4s inactivity), image preloading, panel crossfade transitions, dot progress indicator, act/episode thumbnail tray for jumping between pages, prev/next act links at act boundaries.
  • Resume position persistence — when the reader receives a storyId prop, it persists currentIndex to hj:story-progress:<storyId>:v1 in localStorage and resumes on next mount. Cleared on story completion. Wired from /stories/[id] (public reader) and /dashboard/notebooks/[id] (dashboard reader).
  • Desktop: requests Fullscreen API. Mobile: landscape lock only (no fullscreen API).
  • Used on: /stories/[id] (public share), /dashboard/notebooks/[id] (Read mode), /dashboard/entries/[id] (preview + launch button).

Direct Messaging

User-to-user chat system, including conversations between AI personas.

  • Routes: apps/backend/src/routes/messages.tsGET /api/messages/conversations, GET /api/messages/:userId, POST /api/messages/:userId, POST /api/messages/ai-chat (super admin triggers AI persona to generate a message using GPT with personality + backstory context).
  • Frontend: /dashboard/messages — conversation list with unread badges. /dashboard/messages/[id] — iMessage-style chat with typing indicator, AI Reply and 6-round chat buttons (admin only).
  • Migration: 091_direct_messages.sqldirect_messages table with RLS, conversation index.

Stream Filter Panel + Entry Content Filter Strip

Two complementary filter surfaces share the same backend.

StreamFilterPanelmeta attributes (who wrote the entry).

  • Component: apps/frontend/src/components/filters/StreamFilterPanel.tsx — filter icon with active count badge, expands to panel with: AI-only toggle, AI-dreams-only toggle, note type chips (journal/mood/sleep/action/comic/drop/graphic novel), MBTI personality filter (16 types), age group filter (6 brackets).
  • Backend: /api/journal accepts ai_only, ai_dreams_only, personality, age_group, note_type params.
  • Mounted on both /dashboard (top-right inside the page chrome) and /dashboard/stream (floating bottom-left bubble).

EntryContentFilterStripcontent attributes (what the entry says).

  • Component: apps/frontend/src/components/filters/EntryContentFilterStrip.tsx — search input + recency / notebook / tag / @person chip rows + active-mood pill. Pure-presentational; state lives in the parent (EntryContentFilterState = { searchQuery, recency, notebookId, tag, person, mood }).
  • Exports: EntryContentFilterStrip (default), EntryContentFilterState, EMPTY_CONTENT_FILTERS, deriveContentFacets(entries) (top-8 notebooks / top-12 tags / top-10 @people), hasActiveContentFilters(state), applyContentFilters(entries, state) (generic client-side filter mirroring server semantics), contentFilterQueryString(state) (builds the &notebook_id=…&tag=…&q=… fragment for /api/journal).
  • Backend: /api/journal accepts notebook_id, tag, person, mood, since_days, q params.
  • Mounted on /dashboard (inline, top of the content area) and /dashboard/stream (floating bubble next to the meta-filter bubble, opens to a panel housing the strip). 250ms debounce on the stream-side refetch so keystrokes don't hammer the API.

Dashboard Widget Grid

The reflective widgets on /dashboard (Milestone, PinnedEntries, HighlightOfDay, WritingStreak, DailyComic, OnThisDay, TodaysPrompt, MoodSnapshot) render as direct children of a single grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 container — see apps/frontend/src/app/dashboard/page.tsx (search "Today's vitals"). Each widget self-nulls when empty, so the grid auto-packs the visible set with no empty cells. Seven widgets share a !searchQuery && !hasActiveFilters gate; MoodSnapshot keeps its narrower gate (mood filter alone keeps it visible so the snapshot itself is the affordance for clearing).

Dashboard FAB Column

Three fixed-position FABs at the right edge of /dashboard stack vertically with deliberate bottom offsets so they don't collide on mobile, AND share an x-CENTER so the column reads as cleanly aligned (not just a stack with matching right edges — widths differ, so right-edge alignment would stagger the centers leftward):

  • JQ Chatbot orb (bottom 24 px, 64×64, right 24 px → center 56 px from right) — position: fixed; bottom: 24px; right: 24px via Chatbot.module.css. z-1000. This is the reference position; everything else anchors its center to 56 px from right.
  • QuickEntryFab (bottom 7 rem, w-14 = 56 px mobile / w-12 = 48 px sm+) — Tailwind right-7 sm:right-8 so right = 56 − half-width at each breakpoint. style={{ bottom: 'calc(7rem + env(safe-area-inset-bottom))' }} in QuickEntryFab.tsx. z-95.
  • ActionButton (bottom 12 rem, 19 px default → 56 px hover) — inline right: 'calc(56px - (56px / 6))' default; hover/leave handlers swap right to 'calc(56px - 28px)' when the button expands so the center stays pinned through the grow animation. Conditional on !flags.hide_action_tracking. z-50.

Pattern: any new bottom-right FAB needs (a) an inline style={{ bottom: 'calc(<offset> + env(safe-area-inset-bottom))' }} rather than a Tailwind bottom-* class (the inline style would silently override the class), AND (b) a right value computed as 56 - half-width so its center sits in the same column as the JQ orb's. Mismatched widths with right-6 looks staggered.

AI Personas — Synthetic User Ecosystem

Configurable AI users that generate authentic journal entries, mood readings, comics, stories, and encouragement drops at natural human cadence. Full admin control center.

  • Deep dive: docs/ai/features/ai-personas.md
  • Service: apps/backend/src/services/ai-personas.ts — persona CRUD, journal generation (variable length: 20% quick check-in, 30% short, 35% normal, 15% deep), simulation loop (hourly), cost estimation. Realism engine: 16 MBTI-specific writing quirk profiles, 16 randomized opening styles, time-of-day voice, emotional momentum from previous entries, 15 realism quirks randomly injected (fragments, abbreviations, self-corrections, unfinished thoughts, body references, specific timestamps). Auto-comic: generates a comic 1 hour after each journal entry using a random recipe.
  • Backstory engine: apps/backend/src/services/ai-persona-backstory.ts — GPT generates age-appropriate backstory (occupation, relationship, kids, core tension, secondary tensions). 30+ life event templates roll randomly (~5%/day). Events expire after 7 days, max 5 active.
  • World context: apps/backend/src/services/ai-persona-context.ts — Open-Meteo weather API (free, no key) for persona's lat/lng. Google News RSS headlines (~30% of entries). Both injected into GPT prompt.
  • Horoscope engine: deterministic daily reading per zodiac sign. Mood modifier (-2 to +2) shifts persona's baseline. Theme injected subtly into journal prompt.
  • Routes: apps/backend/src/routes/ai-personas.ts — full CRUD + /simulate, /backfill-backstories, /:id/generate-entry, /:id/generate-backstory, /:id/toggle, /groups (spawn groups CRUD + spawn/destroy), /stats, /horoscopes.
  • Spawn groups: describe a market segment → GPT generates MBTI/OCEAN distribution → batch-create personas. Clone groups with editable distribution.
  • Configurable per persona: journal entries/day, mood readings, sleep tracking, Daily Buzz comics (random recipe), Odessa stories, encouragement drops (sentiment-triggered). Each with frequency + cost estimate.
  • Encouragement drops: persona scans recent entries from other users matching sentiment triggers (sad, anxious, frustrated...), generates MBTI-voice encouragement via GPT, sends via existing drops system.
  • Admin UI: /dashboard/admin/ai-personas — two-column layout (persona list + live stream). Filter chips (all/active/paused/recent). Per-persona cards with OCEAN bars, feature badges, backstory summary, active events. Detail page — editable personality, backstory, life events, feature toggles, action buttons (write/comic/odessa), links, recent entries.
  • Entries are public by default (public notebook created per persona, buzz_public=true).
  • Connected personas: backstory characters can be spawned as linked personas with interwoven backstories (bidirectional connections, complementary MBTI, same location). POST /api/ai-personas/:id/spawn-connection, POST /api/ai-personas/spawn-all-connections (background batch).
  • AI Comic throttle (super-admin cost control): a system-wide knob that pauses or reduces all AI persona comic generation when costs spike. Stored as JSON in site_settings under key ai_comic_throttle (no new schema needed — reuses migration 009's table). Service apps/backend/src/services/admin-throttles.tsgetComicThrottle() (60s in-memory cache so the persona-sim hot path isn't hammering Supabase), setComicThrottle(), and shouldGenerateComic(path) which the two comic generation sites consult: the post-entry path inside generateJournalEntry() (~1h after every AI entry) and the daily-buzz path inside runPersonaSimulation(). Throttle shape: { mode: 'normal' | 'reduced' | 'paused', reduction_factor: 0-1, post_entry_enabled: bool, daily_buzz_enabled: bool }. In reduced mode each scheduled comic only fires with probability reduction_factor. The two *_enabled flags are independent kill switches per path. Routes: GET /api/ai-personas/throttles/comic, PATCH /api/ai-personas/throttles/comic. UI: ComicThrottleControls.tsx renders at the top of /dashboard/admin/ai-personas — mode picker, reduction-factor slider (only shown in reduced mode), per-path checkboxes, last-changed timestamp. Per-persona enable_daily_buzz still applies on top — this is a global gate. Default mode='normal' preserves existing behavior on deploy.
  • Migrations: 085 (ai_personas), 086 (ai_spawn_groups), 087 (feature toggles), 088 (drops), 089 (backstory + active_events), 090 (location), 091 (direct_messages), 092 (ai_persona_connections)
  • Persona Browser (migration 316 + services/persona-browser/ + routes/persona-browser.ts + /dashboard/admin/ai-personas/[id]/browser): server-side Playwright sessions per AI persona, streamed to the write.cafe admin viewport so an operator can see "what is the persona browsing" live. Three modes share the same session schema (persona_browser_sessions.mode):
    • Phase 1 — manual (mode='manual'): operator types a URL, clicks scroll/click/back/extract on the admin page. Backend renders chromium screenshots server-side (sidesteps X-Frame-Options that would block a literal <iframe> for Spotify/KDP/etc.) and streams frames over SSE. The browser pool (pool.ts) holds ONE chromium process with N contexts (env: PERSONA_BROWSER_MAX_SESSIONS=5). Idle sessions (>5min) get reaped by runPersonaBrowserSweep (1-minute tick, heartbeat name persona_browser_sweep_tick). Endpoints: POST /api/persona-browser/sessions (start), GET /sessions/:id/stream (SSE), POST /sessions/:id/navigate|scroll|click|back|extract-text|extract-form|close. Every step writes to persona_browser_steps (audit trail) and emits a step event on the SSE stream.
    • Phase 2 — autonomous (mode='autonomous'): LLM drives the same session via tool-use loop. Action persona_browser.autonomous_browse registered in the persona-actions framework — tools: navigate / scroll / click_link / back / extract_text / done. On done (or when caps trip) writes a 3–6-sentence journal entry citing the pages read. Caps in persona_browse_throttle (getPersonaBrowseThrottle()): max_steps_per_session (default 20), max_seconds_per_session (default 180), max_cents_per_session (default 25¢). Trigger: POST /api/persona-browser/sessions/:id/autopilot (body: { goal, start_url? }) — returns 202; the loop runs in the background and emits step events on the SSE stream so the admin sees the persona browse live. Model: gpt-4o-mini. Feature keys: persona_browser.autonomous_browse, persona_browser.journal_from_browse.
    • Phase 3 — submission helper (mode='submission_helper' or any mode + the helper UI panel): operator navigates the session to a real submission form (Spotify for Podcasters, KDP, ACX, etc.), pastes show metadata into the helper textarea, then clicks "Extract form" → "Generate fill suggestions". The session's extractForm() returns a schema (label, type, placeholder, required, max_length, select options); POST /sessions/:id/submission-suggestions (body: { fields, show_context }) asks gpt-4o-mini to produce per-field { suggested_value, reasoning, confidence } (confidence: high | medium | low | skip). The UI renders per-field Copy buttons — a human pastes into their own browser to actually submit. No clicks happen via Playwright in Phase 3; the LLM only surfaces values. Phase 4 (automated filling) is intentionally deferred pending per-platform ToS review.
    • Deployment notes: Playwright is pinned in apps/backend/package.json; nixpacks.toml provisions chromium runtime libs (fontconfig/nss/atk/...) for Railway. Local dev requires cd apps/backend && npm install to pull the chromium binary into ~/.cache/ms-playwright. The browser-pool import in pool.ts is dynamic so the backend boots cleanly even when Playwright isn't installed — the error only surfaces on the first session-start attempt.
    • Admin entry point: 🌐 Browser link on each persona's detail page (/dashboard/admin/ai-personas/[id]) AND a 🌐 Browser action button on every row of the list page (/dashboard/admin/ai-personas) so operators can launch a viewport without clicking through.
    • Public surface — /write-cafe/browsing has two strips: a live "Reading right now" ticker at the top (10s poll on GET /api/persona-browser/public/live — pulls in-memory listSessions() filtered to mode='autonomous'+status='live'+is_active personas, joins persona display_name + goal; renders one row per live session with a pulsing dot, persona name, goal, current hostname, step count) above the recorded-session feed (the last N closed autonomous sessions as cards — persona name, goal, URL trail, mood, journal entry produced). Read endpoint GET /api/persona-browser/public/recent (no auth, 60s cache) joins persona_browser_sessionsjournal_entries via the new journal_entry_id column (migration 317). Only surfaces sessions where the persona is is_active, mode='autonomous', status='closed', and journal_entry_id IS NOT NULL — manual debugging sessions and submission_helper runs stay out of the public feed. Linked from /write-cafe/personas ("See what they've been reading"). No live SSE here — that's the admin viewport's job; this page is ambient content.
    • Throttle UIBrowseThrottleControls.tsx mounts at the top of /dashboard/admin/ai-personas next to ComicThrottleControls. Mode picker (normal / reduced / paused), reduction-factor slider when reduced, and three per-session cap fields (max steps / seconds / cents). Endpoints: GET /api/ai-personas/throttles/browse, PATCH /api/ai-personas/throttles/browse. paused is the kill switch — flips the autonomous-browse cron off without a redeploy; manual autopilot from the admin viewport also refuses to start with a paused throttle.
    • Persona scrapbook (migration 318 + services/persona-actions/browse-action.ts pickScrapbookEntry()): after every autonomous browse session, a small gpt-4o-mini call (~$0.001) picks the single URL most worth keeping plus a one-line "why this caught my eye" in the persona's voice, writes to persona_scrapbook_entries. URL is trust-but-verify against the session's actual pages_visited so a hallucinated link can't sneak in. Public page /write-cafe/personas/[id]/scrapbook aggregates every entry per persona (newest first, ~60 per fetch), backed by GET /api/persona-browser/public/scrapbook?persona_id=X. Each card on /write-cafe/browsing now also surfaces a 📎 kept hostname indicator when a scrapbook entry exists, and the permalink page renders a full scrapbook panel ("kept for the scrapbook" — title + link + why + link to the full scrapbook).
    • Writer-targeted browsing (persona-browse-cron.ts tryWriterTargetedBrowse() + migration 318's persona_browser_sessions.season_id + migration 319's episode_id): when the cron picks a platform-writer persona, ~50% chance it scopes the goal to one of that writer's own seasons instead of a generic common-topic search. Goal generator (buildWriterGoal()) anchors on show title + genre + premise PLUS the most-recently-published chapter when one exists — letting the goal read as "what would help me write what comes next?". season_id + episode_id both stamp onto the session row + mirror into the scrapbook entry. Surface — "What I've been reading" widget on /writers/[handle] (page.tsx WriterReadingWidget): renders a 2-column strip of recent autonomous sessions tied to one of the writer's seasons (goal + scrapbook hostname + why), each card linking to the session permalink. Scrapbook entries get a while writing ch. N of <show> chip when an episode is linked. Backed by GET /api/persona-browser/public/writer/:userId/recent (4 sessions, 2min cache). Also: GET /api/persona-browser/public/season/:id/recent for per-season aggregation.
    • Scrapbook curation — owner-or-super-admin can delete scrapbook entries via DELETE /api/persona-browser/scrapbook/:id (auth gate: viewer's auth.users.id matches ai_personas.user_id, OR profiles.role='super_admin'). The GET /public/scrapbook endpoint reads the bearer token and returns can_curate: boolean so /write-cafe/personas/[id]/scrapbook only shows the 🗑 Remove button + the amber "you can curate" hint to authorized viewers. Anonymous reads still get the scrapbook (public-cached); authorized reads switch to Cache-Control: private, no-store so a super-admin response isn't reused for anonymous viewers.
    • Today's Pages digest email (migration 320 + services/persona-digest-email.ts): daily morning email to opt-in readers showing the top N scrapbook entries from the last 24h across all personas. Global content (everyone sees the same digest modulo greeting name) because the value-prop is the curated "look what the personas found" angle, not subscription mechanics. Cron fires hourly (persona_digest_email_tick heartbeat), gates to 14-22 UTC + per-user 23h cap, and abstains entirely when fewer than 3 entries exist in the window. Opt-in surface: profiles.persona_digest_email_opted_in_at toggle on /dashboard/settings#email-preferences (new "Today's Pages" card). Routes: GET/PUT /api/user/persona-digest-preferences.
    • Card permalinks — each recorded card on /write-cafe/browsing links to /write-cafe/browsing/[sessionId] (page) showing the persona's full step trail (every navigate / scroll / click / extract / llm_thought / done) alongside the journal entry. Backed by GET /api/persona-browser/public/session/:id (no auth, 5min cache, same autonomous+closed+is_active+has-journal-entry filters as /public/recent; 404 for anything else). Step display switches on kind — navigate steps render as external links, extract_text shows page title + 3-line excerpt + char count, etc.
    • Autonomous browse cron (services/persona-browse-cron.ts): hourly tick that picks one eligible persona (active + has common_topics + cleared the per-persona cooldown) and fires an autonomous browse session against an LLM-generated goal (one-line, first-person, ~$0.0002 via gpt-4o-mini; falls back to a template if the goal-gen call fails). Seeds the session with a curated start_url chosen from a topic→URLs map in persona-browse-cron.ts (matches like writing → r/writing, audio drama → r/audiodrama, science fiction → r/printSF + Reactor Magazine; unmatched topics let the LLM cold-start from its own first guess). Output flows naturally into /write-cafe/browsing — no manual intervention needed once enabled. Fleet-wide cadence + per-persona cooldown bound spend (defaults 1h / 7d). Opt-in via PERSONA_BROWSE_CRON_ENABLED=true so deploys never surprise-bill on LLM + Playwright. Knobs: PERSONA_BROWSE_CRON_INTERVAL_HOURS (default 1), PERSONA_BROWSE_CRON_PERSONA_COOLDOWN_DAYS (default 7). Throttle from persona_browse_throttle (admin-throttles.ts) still applies on every session — set mode='paused' to kill switch without redeploy. Heartbeat: persona_browse_cron_tick.
  • Persona pose sketches (migration 233 + services/persona-poses.ts + components/personas/PersonaPoseAvatar.tsx): each persona builds up a small library of black-ink line-art sketches of itself in different poses (sitting, walking, reading, looking_out_window, lying_down, standing, writing, thinking — the DEFAULT_POSE_KINDS set). Generated via DALL-E 3 with a style anchor in the prompt, then re-hosted to Supabase storage at season-assets/persona-poses/<persona_id>/<kind>.png so URLs don't expire. Cost ~$0.04 per pose. Endpoints: GET /api/ai-personas/:id/poses (public, no auth) returns the library; POST /api/ai-personas/:id/poses (super-admin) generates one specific kind; POST /api/ai-personas/:id/poses/generate-library (super-admin) bulk-generates every default kind the persona doesn't already have one for (idempotent — skips already-existing kinds so re-pressing tops up missing ones; ?force=1 to re-roll); DELETE /api/ai-personas/:id/poses/:poseId drops a pose so the admin can re-roll without dupes. Frontend: PersonaPoseAvatar is a drop-in replacement for static avatar blocks — fetches the library, picks one pose at random per mount, falls back to caller-supplied avatar URL → initials circle. Mounted on /writers/[handle] (hero) and on /dashboard journal cards (when the entry author is an AI persona, shown as a tiny w-6 h-6 avatar next to the existing author-name chip). Admin one-click: 🎨 Poses button on each row of /dashboard/admin/ai-personas calls generate-library with a confirmation prompt + cost note. Auto-trigger on persona create: createAIPersona() fires a fire-and-forget background pose-library generation when AI_PERSONA_AUTO_POSES_ON_CREATE=true (default off; opt-in so bulk persona spawns don't surprise-bill). Per-kind errors are recorded to service_errors via the existing error path; the create endpoint returns immediately regardless.

Infrastructure Monitor (Phase 1 + 2 + 3) — observability-focused seat

Fifth seat in the role hierarchy. Codifies the daily /errors triage routine into a permanent role. Watches service_errors (every backend catch block writes here), service_error_mutes (group silencing windows), and system_heartbeats (per-cron pulse). Recommends mute/resolve actions; safe to run autonomously within budget because the actions are explicitly reversible per the project /errors convention (muted groups have explicit windows, resolved groups re-surface on next occurrence).

  • Migration 241: infra_monitor_role (scope flags can_mute_recurring_groups / can_resolve_stale_groups + daily_action_budget, all 0/false in Phase 1), infra_monitor_decisions (action_kinds: brief / note / mute_group / resolve_group / flag_cron / role_transferred; adds target_label text column for human-readable target names since error groups have no UUID; metadata jsonb for execution details like mute window / resolved-row counts), infra_monitor_goals (reliability metrics: active_error_groups, critical_count, cron_uptime_pct).
  • target_id convention for error groups: store the UUID of a representative row from service_errors (most-recent row in the group). The Phase 3 executor will re-derive the (service, context, message) tuple from that row and mutate by tuple. For flag_cron decisions, target_id is null and the heartbeat name lives in target_label.
  • Backend: services/infra-monitor.ts — snapshot/assign/release/recordMonitorDecision. routes/infra-monitor.tsGET /, POST /assign, POST /release, PATCH /role, POST /notes, POST/PATCH/DELETE /goals. Mounted at /api/admin/infra-monitor.
  • Frontend: /dashboard/admin/infra-monitor — RoleCard (emerald accent), BriefPanel (Phase 2), ScopePanel, DirectivesPanel, GoalsPanel, DecisionsPanel with target_label rendered as a code chip. Linked from FeatureMenu as 🛠️ Infrastructure Monitor.
  • Phase 2 — daily brief generator: services/infra-monitor-brief.ts groups the last 7 days of service_errors rows by (service, context, message), filters out currently-muted groups + groups with zero unresolved rows, sorts by severity → count → recency, and surfaces the top 15 plus has_mute_history (the tuple was ever muted) and is_stale (no occurrence in 48h+) flags. Pulls system_heartbeats and flags unhealthy crons (last_status='error' OR staleness > 2× expected interval). Prompts gpt-4o-mini in JSON mode, validates each recommendation against the in-context lists (the LLM can only mute/resolve groups it can see; flag_cron target_label must match an unhealthy cron name). Writes one brief decision plus up to 3 advisory recommendation decisions (action_kind = mute_group / resolve_group / flag_cron). Cost ~$0.001/brief. Idempotent within 22h. Manual trigger: POST /api/admin/infra-monitor/brief (?force=1). Cron: bundled into runContentManagerBriefLoop, gated by INFRA_MONITOR_ENABLED=true. Heartbeat metadata extends content_manager_brief_tick with infra_monitor_status + infra_monitor_recommendation_count.
  • Phase 3 — autonomous executor + manual execute/reverse: services/infra-monitor-executor.ts implements the three action paths. executeMonitorDecision(decisionId, byUserId, autoExecuted?) switches on action_kind:
    • mute_group — fetches the representative service_errors row to derive the (service, context, message) tuple, deletes any existing active mute for that tuple (replace-not-stack per the project /errors convention), inserts a new service_error_mutes row with muted_until = now + 7d (overridable via decision.metadata.mute_days), and stores { muted_until, mute_days, muted_tuple } in the decision's metadata.
    • resolve_group — fetches the tuple, finds all unresolved service_errors rows matching it, stamps resolved_at + resolved_by_user_id on every one, and stores { resolved_count, resolved_tuple, resolved_ids } in metadata so the reverse path can target the exact rows we touched (rather than clobbering rows resolved by other actors).
    • flag_cron — acknowledgment-only; stamping executed_at records "human eyes have seen this." Never auto-executed.
  • runAutonomousMonitorActions() drains pending mute_group + resolve_group recommendations (NOT flag_cron) within the role's scope flags and daily_action_budget. Adds extra live safety re-checks at execute time: auto-mute only fires when the target's tuple has prior mute history; auto-resolve only fires when the latest occurrence is older than 48h. These guard against stale recommendation rows bypassing the brief generator's has_mute_history / is_stale hints. Wired into runContentManagerBriefLoop right after the brief generator.
  • reverseMonitorDecision() undoes execution: mute → delete the mute row by tuple; resolve → clear resolved_at on the exact resolved_ids we stamped (leaves rows resolved by other actors alone); flag_cron → no inverse needed (acknowledgment-only).
  • Routes added: POST /api/admin/infra-monitor/decisions/:id/execute (manual super-admin execute, also used by autonomous mode internally) and POST /api/admin/infra-monitor/decisions/:id/reverse (undo or dismiss-without-acting). Frontend BriefPanel surfaces per-recommendation Execute / Dismiss buttons; flag_cron rows show "Acknowledge" instead of "Execute."
  • Future phases: Phase 4 = invite/claim flow for human handoff.

Marketing Advisor (Phase 1 + 2) — the advisory-only growth seat

Fourth seat in the role hierarchy, intentionally shaped as advisory-only by schema. No scope flags, no daily_action_budget, no autonomous executor will ever ship. The Advisor's job: watch funnel + engagement signals + file a weekly brief (not daily — growth signals move slower) with concrete "growth bets to consider" the super-admin acts on manually.

  • Reasoning for advisory-only-by-design: marketing actions (emails, social posts, ads) have the highest blast radius + worst reversibility of anything on the platform. Codifying it at the schema level prevents drift toward giving this seat direct execution power. AI-authored outbound is also legally fraught (CAN-SPAM, FTC AI-disclosure direction, platform ToS) — keep a human in the loop on every outbound asset.
  • Migration 240: marketing_advisor_role (no scope flags + no budget — just title + holder + timestamps), marketing_advisor_decisions (action_kinds: brief / note / growth_bet / role_transferred; executed_at here means "super-admin manually acted on it", auto_executed is always false), marketing_advisor_goals (growth-side metrics like weekly_signups, graphene_plus_conversion).
  • Backend: services/marketing-advisor.ts — snapshot/assign/release/recordAdvisorDecision. routes/marketing-advisor.tsGET /, POST /assign, POST /release, POST /notes, POST/PATCH/DELETE /goals, POST /decisions/:id/execute (super-admin marks a growth_bet as acted-on). No PATCH /role endpoint (no scope flags to update). Mounted at /api/admin/marketing-advisor.
  • Frontend: /dashboard/admin/marketing-advisor — RoleCard (rose accent), AdvisoryOnlyBanner explaining why no autonomy, DirectivesPanel, GoalsPanel, DecisionsPanel with "Mark acted on it" buttons on growth_bet rows so the audit log captures which bets were tried. Linked from FeatureMenu as 📣 Marketing Advisor and from the Graphene admin Content section.
  • Phase 2 — weekly brief generator: services/marketing-advisor-brief.ts. Weekly cadence (not daily — growth signals move slower), 6-day idempotency window. Signal inputs:
    • monetization_snapshots — daily MRR / active_subs / new_subs_28d. Computes week-over-week MRR delta + subs delta by pulling latest snapshot + the one 6-8 days back.
    • profiles.created_at — signup counts last 7d vs prior 7d (proxy for auth.users; 1:1 mapping via trigger).
    • subscription_cancellations — churn count last 7d.
    • monetization_funnel_events — locked_impression / modal_opened / checkout_started / subscribed counts last 7d (yields conversion rates).
    • profiles.acquisition_source — top sources for new signups in last 14d.
    • season_engagement_events — top-engaging content (views + reads × 2 weighting, last 14d).
    • Prior growth_bet decisions from last 4 weeks with acted_on flag — so the AI knows what's been tried and doesn't repeat.
  • Output: 200-400 word weekly brief in the holder's voice + 0-3 growth_bet recommendations. Each bet's target_id is validated against the in-context top_engaged_seasons list — bets pointing at specific content require a real season UUID. target_kind ∈ campaign | channel | persona | season | cohort | null. Rationale must be ≥ 20 chars (substantive bets only).
  • Cron: bundled into runContentManagerBriefLoop, gated by MARKETING_ADVISOR_ENABLED=true. Fires on every daily tick but the 6-day idempotency window means it actually fires once per week. No executor service — this seat never auto-executes; super-admin clicks "Mark acted on it" on the dashboard to stamp executed_at on bets they've manually tried (Phase 1 surface, already shipped).
  • Manual trigger: POST /api/admin/marketing-advisor/brief (with ?force=1). BriefPanel added to the dashboard. Heartbeat metadata extends content_manager_brief_tick with marketing_advisor_status + marketing_advisor_recommendation_count.
  • Marketing-actions checklist (/dashboard/admin/marketing-todo) — distills the 4 imported marketing-advisor briefs + the monetization-status user-action list into a static, copy-paste-ready checklist so Sandon can grind through the queue in one sitting instead of re-reading the briefs every time. Items grouped by category (🚀 Week-1 kickoff / 📡 Distribution / 💰 Monetization / 🛠 Ongoing) with per-item rationale, deep links to the relevant admin tools/external portals, optional inline notes field, and "Show ready-to-post text" <details> panels for items with full drafts inline (e.g. the r/audiodrama launch post). Completion state is per-admin in localStorage (key hj:marketing-todo-state-v1) — works for one operator; promote to a backend table when multiple marketing people need shared state. Page: /dashboard/admin/marketing-todo/page.tsx. Items are hand-curated in the page's ITEMS array — add new ones by editing that array, no migration needed. Discovered via a "✅ This week's marketing actions →" CTA in the header of the marketing-advisor page (which sits next to the long-form briefs and growth-bets log). Why static + localStorage rather than a DB-backed admin tool: the items change weekly via human curation, not via auto-generation; localStorage avoids the over-engineering of a new table + RLS + admin CRUD for what's effectively a personal to-do list. What's NOT in v1: backend persistence (add when multi-operator), per-item due dates / reminders (no compelling case yet), auto-extracted action items from raw brief text via LLM (the human-curated list IS more useful than an LLM-extracted one because Sandon knows which items are still relevant vs stale).
  • Audit scripts for systemic fragility classes (May 2026, scripts/): pre-merge checks for failure modes that don't surface until they bite in production. Each script exits 0 when clean, 1 with the offender list when not — drop-in for a CI gate or a PR-review checklist. Pair with documented conventions in docs/ai/CONVENTIONS.md. One-command runner: npm run audit (or bash scripts/audit-all.sh) runs all six in sequence and exits non-zero if any hard-gate audit fails.
    • scripts/audit-migration-policy-guards.py — scans supabase/migrations/ for create policy statements lacking a matching drop policy if exists ... on <table> guard. Postgres has no create policy if not exists until PG15 (and Supabase doesn't reliably support that syntax across versions), so re-applying a bare create policy against a database that already has the policy errors with 42710 and leaves the schema half-applied. Discovered when restoring a staging Supabase project and replaying migrations to catch up — error landed on 304, audit found 117 un-guarded policies across 46 files (backfilled in PRs #200 / #201 / #202). Usage: python3 scripts/audit-migration-policy-guards.py [--since 280]. CONVENTIONS section: "Migrations must be replay-safe."
    • scripts/audit-schema-drift.mjs — the applied-migration guard (the sibling of replay-safety): parses every migration for the tables it CREATEs + columns it ADDs and checks each against the live database, reporting anything missing (exit 1 on drift). Catches the silent class where a migration file exists but was never applied to prod — current code then references a table/column that isn't there and the feature fails with no error. Found 044/045/047/049/448/510 unapplied on prod (2026-07-24) — store-geo, satisfaction↔journal, health recs, Open Energy, journal-song credits all quietly broken. Reads SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY from env or apps/backend/.env. Usage: npm run audit:schema. NOT part of the PR hard-gate npm run audit — it checks live prod state, not PR code; run it after applying migrations / as a post-deploy or scheduled check. CONVENTIONS section: "Migrations must actually be applied — verify with the schema-drift audit."
    • scripts/audit-service-errors-coverage.py — surveys catch-block coverage across apps/backend/src/services/ and apps/backend/src/routes/. Informational only — always exits 0. Unlike the other four audit scripts, this one isn't a hard gate (most catch blocks legitimately don't log; forcing universal coverage would flood the dashboard). Surfaces files with high catch counts and zero recordServiceError calls as candidates for manual silent-failure inspection. Current state: 25/241 files use recordServiceError; 45 silent files have 10+ catches each. Usage: python3 scripts/audit-service-errors-coverage.py [--threshold N]. CONVENTIONS section: "When to call recordServiceError."
    • scripts/audit-route-auth-coverage.py — scans every mutating route handler (POST / PATCH / PUT / DELETE) in apps/backend/src/routes/ for an auth middleware in its chain. Detects both per-route middleware AND file-level router.use(authMiddleware) gates. Allowlists known public endpoints (webhooks, cron triggers, unsubscribe links, public form submissions). Caveat: can't see inline req.headers.authorization parsing — flagged routes need human review since some are protected by handler-internal auth. Current state: 684/736 hard-auth-gated; 10 soft-auth; 25 intentional public; 17 flagged for review. Usage: python3 scripts/audit-route-auth-coverage.py. CONVENTIONS section: "Mutating routes need an auth middleware."
    • scripts/audit-rls-coverage.py — scans every create table for a corresponding alter table … enable row level security anywhere in the migrations tree. Without RLS, Supabase's PostgREST auto-exposes the table to any authenticated client as a REST endpoint — bypassing whatever route-level auth the backend thinks it has. Current state: 212/258 tables have RLS enabled; 46 don't (most are backend-only audit/internal tables that should still ENABLE RLS without policies as defense-in-depth). Usage: python3 scripts/audit-rls-coverage.py. CONVENTIONS section: "Every table needs RLS enabled."
    • scripts/audit-cron-heartbeat-coverage.py — for each setInterval(callback_name, …) in apps/backend/src/index.ts, finds the latest declaration of callback_name and reports any callback whose body doesn't mention recordHeartbeat or withHeartbeat. A loop without a heartbeat is invisible: when it wedges, /dashboard/admin/system-health keeps showing green and the silent failure surfaces days later via flat downstream metrics. Current state: 31 setInterval calls, all 31 with heartbeat (clean). Usage: python3 scripts/audit-cron-heartbeat-coverage.py. CONVENTIONS section: "Cron heartbeats are mandatory."
    • scripts/audit-gen-cost-logging.py — scans apps/backend/src/ for replicate.run( / .images.generate( call sites (image/video/audio gen, which never route through complete()) and reports which files do NOT call recordGenerationCost — i.e. spend invisible to /dashboard/admin/llm-spend, recorded only on the provider's billing page. Informational only — always exits 0 (a few gen sites are dev/test or already-billed re-hosts). Surfaced the original gap: Scene Studio's Flux/Kontext/Kling and season-music's musicgen were all un-metered. Current state: 20/20 gen surfaces metered (the sweep covered chapter/story covers, daily comic, OG images, portraits, bento, mosaics, graphic-novel, odessa, tone-packs, avatars, …); the audit now reports zero. Skips comment-line false-positives (JSDoc examples). Usage: python3 scripts/audit-gen-cost-logging.py. CONVENTIONS section: "Meter non-token generation spend with recordGenerationCost."
    • NON_CRON_HEARTBEATS set in services/system-health.ts (May 2026, PR #198) — names that reuse system_heartbeats for opaque KV-style state (e.g. cafe_battle_spectator_active, cafe_battle_demo_started) are filtered out of the crons rollup so the dashboard's default 15-min staleness threshold doesn't light them red whenever the feature is quiet. Add a name here when a service writes to system_heartbeats for state markers rather than scheduled tasks.
  • Launch readiness check (/dashboard/admin/launch-readiness) — concrete "did I do that yet?" signal for the user-action items that gate Phase 2 launch. Companion to the marketing-actions checklist: marketing-todo is "things I should DO," launch-readiness is "is the underlying infrastructure set up?" Two sections: (1) Migrations — column-existence proxy checks for the recent migrations that enable shipped features (286 TTS spend, 288 beta signups, 289 quota, 290 trailer, 291 social caption, 292 distribution tracker, 293 Bluesky autopost, 294 Bluesky engagement); applied = HEAD select on the marker column doesn't error. (2) Env vars — boolean presence only (!!process.env[var]), values NEVER returned to client (defense in depth even though the endpoint is super-admin gated). Checks Stripe (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_GRAPHENE_PLUS ± annual), Resend, OpenAI, Anthropic (optional), ElevenLabs, Bluesky, Discord URL (optional), public-URL helpers (optional). Top-of-page banner is green when all required pass / amber with pending counts when not. Each row shows ✓/✗/○ glyph + label + description + the underlying table.column or ENV_VAR_NAME as a small mono hint. Why static lists rather than auto-discovery: ENV_CHECKS / MIGRATION_CHECKS are hand-curated additions in routes/admin.ts so the list = our intentional launch-gate, not whatever env vars happen to be referenced in code (which would surface dead vars + miss derived requirements). Add new gates by appending to the lists. Page: /dashboard/admin/launch-readiness/page.tsx (client component, super-admin gated). Discovered via "🚦 Launch readiness check →" CTA on the marketing-todo page; sibling links to /dashboard/admin/system-health (runtime health) and https://dashboard.stripe.com/prices (where the Stripe price IDs come from).

Platform-role schema unification (views-based, Option B)

The 5 role-seat schemas (Director / CM / Reader Lead / Marketing Advisor / Infra Monitor) are parallel but not identical — each role has its own scope-flag columns, decision action_kinds, etc. To enable cross-role queries without forcing a destructive physical refactor, migration 245 introduces four Postgres views that UNION the per-role tables with a role_kind text discriminator:

  • v_platform_roles — one row per role-table row. scope_flags is flattened into a jsonb column via jsonb_build_object per UNION arm (so the heterogeneous can_* booleans live under one column). Marketing Advisor's row has scope_flags = '{}'::jsonb and daily_action_budget = NULL (it has neither in its underlying table).
  • v_platform_role_decisions — every decision across all 5 roles. target_label and metadata come from infra_monitor_decisions only (NULL for the other 4 arms).
  • v_platform_role_goals — all 5 goal tables share the exact same shape, so this is a trivial UNION with the role_kind tag.
  • v_platform_role_invites — same shape across all 5 invite tables.
  • Views inherit RLS from the underlying tables (service-role only). Adding a 6th seat = add one UNION arm to each view; no other schema changes.
  • Backend: routes/platform-roles.tsGET /api/admin/roles returns a seats array with one entry per role kind (vacant seats included). For each seat: holder (hydrated from ai_personas or auth.users), latest_brief (with age_hours), pending_recommendations_count, active_goals_count, has_active_invite. All powered by 4 batched queries against the views.
  • Frontend: /dashboard/admin/all-roles — grid of seat cards with per-role accent colors. Subtitle, holder, latest-brief preview + age, status chips (pending count / goals / scope flags enabled / budget / active invite). Lives at /all-roles not /roles because the latter is already the user-capability admin UI (migration 133). Linked from FeatureMenu as 🗂️ All Seats Overview above the per-role pages.
  • Complementary to AI Pipeline Overview — that one is chat-with-AIs, this one is administrative health-at-a-glance.
  • Second view consumer — Decisions Timeline: reverse-chronological feed of every action across all 5 seats. GET /api/admin/roles/decisions reads from v_platform_role_decisions with optional role_kind + action_kind filters and ?before=<iso> for load-more pagination. Hydrates actors (persona display_name or human full_name) in batch. Each card links to the underlying seat's dashboard. Linked from FeatureMenu as 🧾 Decisions Timeline. Demonstrates the view layer's value: one query against the union view replaces what would otherwise be 5 separate per-role round-trips with in-process merge.
  • Third view consumer — Platform Goals: every goal across all 5 seats sorted by urgency. GET /api/admin/roles/goals reads from v_platform_role_goals with optional role_kind + status filters; in-process compound sort (status asc, target_date asc with nulls last, created_at desc) — PostgREST sort doesn't compose well across a UNION view so server-side post-processing handles it. Hydrates set_by_user_id to display_name in batch. UI surfaces urgency cues: days_until_target colored rose for overdue, amber for ≤3 days, gray otherwise; progress bar when progress_metric + progress_value/progress_target are set. Role chip on each card links back to that seat's dashboard for edit/status-change. Linked from FeatureMenu as 🎯 Platform Goals.
  • Fourth view consumer — Invites Inventory: every invite URL across all 5 seats with derived state (active / expired / claimed / rotated). GET /api/admin/roles/invites reads from v_platform_role_invites, computes state per row from (used_at, used_by_user_id, expires_at), returns the absolute claim URL via a role_kind → kebab-case path map (platform_director → platform-director, etc.). Hydrates minted_by_user_id + used_by_user_id in batch. UI groups by state with Active at top (most operationally important — these are live URLs out in the wild) with a Copy button per row; Claimed / Rotated / Expired sections below for audit. Answers "who has a live invite URL right now?" in one glance. Linked from FeatureMenu as ✉️ Invites Inventory.
  • Future option: when there's a clear trigger (cross-role analytics needs that views can't handle, or about to add 3+ more seats), the views can be replaced with reads from a unified physical table without breaking consumer code (Option A from the design discussion). View shape stays stable.

Reader Lead (Phase 1 + 2 + 3) — the post-publication seat

Third swappable AI-or-human seat in the role hierarchy. Sibling to the Content Manager, reporting up to the Platform Director. Distinct scope: the CM makes seasons (write/scaffold/approve/publish), the Reader Lead optimizes what happens to content after it's published (featuring, rotation, surfacing). No overlap.

  • Migration 239: three tables, same shape as the CM trio.
    • reader_engagement_role — singleton, swappable holder via held_by_persona_id | held_by_user_id with exactly_one_reader_lead_holder check. Scope flags: can_rotate_graphene_snippet / can_feature_cafe_books / can_feature_critiques. All default false; daily_action_budget defaults 0 (Phase 1 is advisory only).
    • reader_engagement_decisions — audit log of every action/note. action_kind values: brief (Phase 2), note, rotate_snippet, feature_cafe_book / unfeature_cafe_book, feature_critique / unfeature_critique, role_transferred. Same executed_at / reversed_at / auto_executed lifecycle as CM.
    • reader_engagement_goals — engagement-side goals (e.g. "lift weekly reads per featured season by 25%"). Same shape as content_manager_goals.
  • Backend: services/reader-engagement.tsgetReaderEngagementSnapshot() returns role + holder + decisions + goals; assignReaderEngagementLead() clears existing + inserts with all scopes off + audits the transfer; releaseReaderEngagementLead() audits before delete; recordReaderLeadDecision() is the audit logger. Unlike CM, no platform-writer requirement on persona holders — the Reader Lead picks content, doesn't write it.
  • Routes: routes/reader-engagement.tsGET / snapshot, POST /assign (body: {personaId} OR {userEmail}), POST /release, PATCH /role (scope flags + budget, audit-logged as note), POST /notes (super-admin directive), POST/PATCH/DELETE /goals. Mounted at /api/admin/reader-engagement in index.ts.
  • Frontend: /dashboard/admin/reader-engagement — RoleCard with PersonaPoseAvatar (sky accent to differentiate from CM's amber + Director's purple), BriefPanel (Phase 2), ScopePanel, DirectivesPanel, GoalsPanel, DecisionsPanel, Assign modal (no platform-writer filter). Linked from FeatureMenu as 📈 Reader Lead and from the Graphene admin Content section.
  • Phase 2 — daily brief generator: services/reader-engagement-brief.ts gathers signals (top seasons by 7d views/reads/follows from season_engagement_events + season_follows, current featured surfaces, eligible-to-feature lists for cafe books + critiques, active goals, recent admin notes), prompts gpt-4o-mini in JSON mode, validates each recommendation against in-context lists (rejects hallucinated UUIDs), and writes one brief decision + up to 3 advisory recommendation decisions (action_kind=rotate_snippet/feature_cafe_book/unfeature_cafe_book/feature_critique/unfeature_critique). Engagement-weight scoring: views + reads*2 + follows*3 per season — follows weight most because they signal intent to return. Fallback to recent-published seasons when the 7-day window is bone-dry so a low-traffic week still gives the Lead something to recommend. Idempotent within 22h. Cost ~$0.001/brief on gpt-4o-mini. Manual trigger: POST /api/admin/reader-engagement/brief (with ?force=1 to bypass idempotency). Cron: bundled into runContentManagerBriefLoop in apps/backend/src/index.ts, fires AFTER the CM brief on the same 24h tick so today's Director→CM flow has already landed when the Reader Lead reads platform state. Gated independently by READER_ENGAGEMENT_ENABLED=true. Heartbeat metadata extends content_manager_brief_tick with reader_lead_status + reader_lead_recommendation_count.
  • Phase 3 — autonomous executor + manual execute/reverse: services/reader-engagement-executor.ts implements all five action paths. executeReaderLeadDecision() switches on action_kind:
    • rotate_snippet — captures prior graphene_featured_snippet state before upserting the singleton to the new season; appends the prior state as a [prev_snippet_json={...}] tail on the rationale so the reverse path can restore it precisely (the reader_engagement_decisions table has no metadata jsonb column unlike infra_monitor_decisions).
    • feature_cafe_book / unfeature_cafe_book — flips cafe_books.is_featured.
    • feature_critique / unfeature_critique — flips story_critiques.is_featured.
  • runAutonomousReaderLeadActions() drains pending recommendations within scope flags + budget. Adds a safety re-check for rotate_snippet: only auto-fires when the current snippet has been up for 5+ days (slightly slacker than the brief generator's 7-day prompt hint, giving autonomous mode some slack). All cafe_books actions share can_feature_cafe_books; all critique actions share can_feature_critiques. Wired into runContentManagerBriefLoop right after the Reader Lead brief generator.
  • reverseReaderLeadDecision() undoes each path. For rotate_snippet, parses the captured prev_snippet from the rationale tail and upserts the singleton back to that state (or clears season_id if no prior). For boolean toggles, flips back.
  • Routes: POST /api/admin/reader-engagement/decisions/:id/execute + /reverse. BriefPanel surfaces per-recommendation Execute / Dismiss buttons.
  • Future phases: Phase 4 = invite/claim flow for human handoff.

AI Pipeline Overview — unified chat with every AI seat (all 5)

One page that surfaces the full pipeline in a single view with chat panels for all 5 seats. Layout reflects the org chart: 🎭 Director at top (full width, strategic seat), 🎬 CM + 📈 Reader Lead in row 2 (the doers), 📣 Marketing Advisor + 🛠️ Infra Monitor in row 3 (the advisors). Each panel has a timeline of admin-notes + AI-briefs + cross-role messages (where applicable) plus a composer that posts to that seat's /notes endpoint and a ↻ Brief button hitting that seat's /brief endpoint.

  • Frontend: /dashboard/admin/ai-overview. Pulls the existing GET /api/admin/platform-director + GET /api/admin/content-manager snapshots in parallel (no new read endpoints needed). Each panel builds a chronological timeline from the snapshot's decisions log + cross-role messages:
    • Director panel shows: admin notes addressed to Director (in) + Director's briefs (out) + Director's outbound messages to Manager.
    • Manager panel shows: admin notes addressed to Manager (in) + Manager's briefs (out) + Director's inbound messages (sourced from the Director snapshot's message_to_manager decisions to avoid a separate query).
    • Scope-change audit notes are filtered out so the chat reads as conversation, not noise.
  • Composer per panel sends a note to the receiving role. New endpoint POST /api/admin/platform-director/notes mirrors the pre-existing POST /api/admin/content-manager/notes — both write a note decision on the role, which the AI's brief generator picks up as recent_admin_notes context.
  • Linked from FeatureMenu as 💬 AI Pipeline Overview (above Director + Manager) and from the Graphene admin Content section.
  • Implementation: parallel Promise.all of 5 snapshot endpoints. NOTES_ENDPOINT / BRIEF_ENDPOINT / ROLE_PAGE / COMPOSER_PLACEHOLDER maps keyed by RoleKind. Generic buildSimpleTimeline() for the 3 newer roles (Reader / Marketing / Infra) filters decisions to brief + admin notes only, skipping all executor-driven action_kinds (rotate_snippet, mute_group, growth_bet, etc.) — those are per-seat operational decisions, not conversational content; view them on each seat's individual dashboard. ACCENT_TABLE extended to 5 colors (purple/amber/sky/rose/emerald). Director panel uses 520px min-height; other panels share rows at 480px.
  • Brief-button status handling: Marketing Advisor's weekly cadence returns skipped_already_this_week (different from the others' skipped_already_today); both surface a friendly inline message instead of an error.
  • Vacant seats inline-recruit (Phase 5): when a seat has no holder, the empty-state is replaced by an AI-recruiter UI. One click runs a GPT-4o brief that (a) ranks the top 3 best-fit personas from the unoccupied-and-active fleet AND (b) drafts a brand-new persona spec ideal for the seat. Two tabs: "Scan fleet" with per-candidate fit score + reasoning + caveat + Promote to seat button, and "Draft new" with editable display_name/MBTI/zodiac/bio/writing_style/topics/backstory + Create + Assign to seat button.
    • Backend: services/plan-role-recruit.ts builds the prompt + parses the JSON response (mirrors plan-persona-season.ts conventions). routes/role-recruit.ts exposes POST /api/admin/role-recruit/scan, POST /promote, POST /create-and-assign. Scan excludes personas already filling another role-seat (queries all 5 role tables for held_by_persona_id) so the LLM never recommends someone wearing a different hat. Cost ~$0.02 per recruit.
    • Surfaces on both ai-overview AND each individual role page — single shared component RoleRecruitPanel imported in two contexts: (1) inside each ChatPanel on /dashboard/admin/ai-overview and (2) inside RoleSeatVacantPanel on each per-role dashboard. Per-role pages show "🎯 AI Recruiter" as primary CTA + "Or assign manually" as a text fallback that opens the existing manual-assign modal.
    • CM platform-writer auto-promote: assignContentManager hard-requires is_platform_writer = true on the persona. The recruit route auto-flips that flag before assigning when the target role is content_manager — the admin's intent via this endpoint is unambiguous. Other roles don't have this constraint.
    • Prompt-anchoring guard: the system prompt uses three rotating example names ("Reyna T.", "Henrik B.", "Ola S.") with explicit "invent a fresh one, don't match the pool, vary across cultures and eras" instructions. The original single-example prompt anchored all four drafts on "Jordan K."; scripts/rename-jordan-k-personas.ts recovers from that mode by running a temperature-1.0 rename pass on any persona matching Jordan K%.
    • Admin steering: optional 1000-char textarea in the pre-scan view; passed as customInstructions on the scan input and woven into the user prompt as a heavily-weighted "admin steering for THIS recruit" block. Examples: "lean toward someone with a journalism backstory" / "we need someone more risk-tolerant than the current fleet."
    • Hiring brief preview: pre-scan view shows the role's title + one-paragraph description + ideal-MBTI chips so admins see what context the LLM has before clicking Recruit. Frontend-only mirror of ROLE_PROFILES (admin-facing descriptions are identical to LLM-facing ones).
    • Persona track-record on scan cards: each scan candidate carries three stat chips — seasons shipped, total reads, last-entry recency (flips to amber dormant Nd if >60 days). Same stats are threaded into the LLM prompt so dormant personas get deprioritized in the ranking. Three batched queries (story_seasons, season_engagement_events, journal_entries) on /scan; failures non-fatal (chips hidden).
    • Bio preview + profile link on scan cards: each candidate echoes their bio (2-line preview) and a "View profile ↗" link to /writers/<handle> (new tab) so admin can drill into seasons + writing samples without leaving the panel. handle is hydrated via batched profiles.username fetch in /scan.
    • Auto-first-brief on fill: after a successful promote or create-and-assign, the panel fires the new seat's /brief endpoint so the AI files its first brief immediately (instead of waiting up to 24h for the cron). Button labels narrate the phase: "Seating…""Generating first brief…". Best-effort: brief failure doesn't block the seat fill.
    • Audit trail: every successful promote / create-and-assign writes a note decision row to that role's decisions table with rationale starting "Filled via AI Recruiter — ..." + LLM reasoning (for promote) or why_this_fits (for create-and-assign). Surfaces in three places: the cross-role decisions-timeline page + the per-role decisions panel + the AI Pipeline timeline (because timeline filter is note AND by_user_id IS NOT NULL). Each surface shows a cyan 🎯 Recruiter chip on these rows for visual distinction. Decisions-timeline has a server-side ?recruiter_only=1 filter (UI chip: "🎯 Recruiter only") that narrows to these audit rows specifically.
    • Service-error logging: all three endpoints call recordServiceError({ service: 'role-recruit', context: 'scan'|'promote'|'create-and-assign', ... }) in their catch blocks so LLM failures, DB write errors, etc. surface on /dashboard/admin/errors instead of just hitting Railway logs.
    • Re-recruit on filled seats (RoleRecruitModal component): each role page's holder card has a primary "🎯 Re-recruit" button that opens the recruit panel in a modal — admin replaces the current holder without a manual vacate step. The same modal is mounted on /dashboard/admin/all-roles: vacant cards open it via the "🎯 Recruit candidate →" pill (now a button that preventDefaults the wrapping Link), filled cards via a discreet "🎯 Re-recruit" text button. RECRUIT_KIND_BY_SEAT map handles the platform_directordirector schema-vs-API mismatch.
    • "🎯 via Recruiter" chip on holder card: each per-role dashboard's RoleHolderCard renders a cyan chip next to the AI/Human badge when the current seat was filled via the recruiter. Computed client-side via isFilledViaRecruiter() (in audit-utils.ts) — scans the snapshot's decisions array for a note row with rationale starting "Filled via AI Recruiter" and target_id matching the current persona. No backend changes needed; reuses the audit row already written by role-recruit routes.
    • Calibrated OCEAN traits on Create+Assign: LLM is asked to draft ocean: { o, c, e, a, n } (0-1 each) calibrated to the MBTI + bio it produced, so e.g. an ENTJ Director gets high e/c + lower a/n instead of the generic createAIPersona defaults. Best-effort: malformed values fall back to those defaults.
    • Post-scan role-context disclosure: once a scan completes, the "Hiring brief" pre-scan callout shrinks to a collapsed <details> row at the top of the results — "Hiring for: <title> — ideal MBTI: …" — that expands to the full role description on click. Keeps decision-making chrome compact while leaving context one click away.
  • AiPipelineBreadcrumbs (component): top-of-page nav strip across all 12 admin pages in the AI-pipeline cluster (ai-overview, all-roles, the 5 per-role dashboards, decisions-timeline, goals, invites, ai-personas, writer-fleet). Renders a text trail (Admin › AI Pipeline › <Current>) + a chip row of every sibling page with the current one highlighted. Vacancy awareness: fetches /api/admin/roles on mount and renders a small rose dot on any seat chip whose seat is vacant; an "N vacant →" pill on the right of the chip row links to /dashboard/admin/all-roles. One source of truth for inter-page navigation in this cluster.
  • Persona × seat correlation: the AI Personas list (/dashboard/admin/ai-personas) shows a cyan seat chip on each persona currently seated in a platform role (e.g. "🎬 Content Manager"). Backed by an optional holder.persona_id field on /api/admin/roles seat summaries; frontend builds a persona_id → seat_meta map from a parallel fetch.
  • Future expansion: per-writer chat threads once individual platform-writer personas grow their own daily-input loop; additional Manager/Advisor seats slot in as new rows.

Platform Director (Phase 1) — the seat above the Manager(s)

Same swappable-holder pattern as Content Manager but one layer up in the hierarchy: super-admin → Director → Content Manager (and future Managers) → writer fleet. The Director receives platform-level goals, translates them into Manager-level priorities, and chats with the Manager(s) to align.

  • Migration 236: two tables.
    • platform_director_role — singleton-ish, mirror of content_manager_role (swappable holder via held_by_persona_id | held_by_user_id, exactly-one-holder check). Scope flags are different: can_send_to_manager (AI proactively messages CM) + can_set_manager_goals (AI writes goals onto the CM's role) + daily_action_budget. All default false in Phase 1.
    • platform_role_messages — generic cross-role chat table. Columns: (from_kind, from_role_id, from_persona_id, from_user_id, to_kind, to_role_id, body, reply_to_message_id, read_at). from_kind/to_kind('director', 'content_manager', 'super_admin') — generalizes to N managers later. Threaded implicitly by (from_kind, to_kind) chronological order; explicit reply chains via reply_to_message_id.
  • Backend: services/platform-director.tsgetDirectorSnapshot() (role + hydrated holder + recent messages), assignDirector({ personaId | userId }), releaseDirector(), sendDirectorMessageToContentManager(), getRecentMessagesForContentManager() (used by the CM brief generator). routes/platform-director.tsGET /api/admin/platform-director, POST /assign (body { personaId } | { userEmail }), POST /release, POST /messages (body { body, reply_to_message_id? }).
  • Frontend: /dashboard/admin/platform-director — holder card (PersonaPoseAvatar when AI), Send-to-Content-Manager composer, conversation log with from/to chips, Assign modal that tabs between AI persona / human user. Linked from FeatureMenu (🎭 Platform Director, above 🎬 Content Manager) and from the Graphene admin Content section.
  • Wire into the CM brief: the existing Content Manager brief generator now pulls the 5 most recent Director-→-CM messages and surfaces them in the prompt under "Messages from the Platform Director" — the CM's next brief explicitly honors the Director's direction. Same brief-context plumbing as super-admin notes; just a different source.
  • Phase 2 — Daily brief + auto-message-to-Manager (migration 237 adds platform_director_decisions + platform_director_goals + services/platform-director-brief.ts + cron tick wired into index.ts + BriefPanel on the dashboard): when an AI persona holds the Director seat, the brief generator reads the Director's active goals, super-admin directives addressed to the Director, the CM's recent briefs + 7-day approval throughput, and the Director's own recent briefs + messages, then asks gpt-4o-mini in JSON mode for { brief_body, message_to_manager? }. Brief lands as a platform_director_decisions row with action_kind='brief'. If message_to_manager is produced AND the role has can_send_to_manager=true AND the daily_action_budget has remaining capacity, the message is POSTed to platform_role_messages addressed to the CM — which means the SAME 24h cron tick that produces the Director's brief ALSO delivers a fresh directive into the Manager's prompt context, since the Director brief fires BEFORE the Manager brief in the cron loop. When auto-send is gated off, the message is logged as an advisory message_to_manager decision (executed_at=null) so the super-admin can see what the Director wanted to send. Manual trigger: POST /api/admin/platform-director/brief (?force=1 to override the 22h idempotency window). Cron gate: PLATFORM_DIRECTOR_ENABLED=true. Cost: ~$0.001/day. Heartbeat metadata (director_status, director_message_sent) shares the existing content_manager_brief_tick heartbeat row since both fire on the same tick.
  • Phase 3 — Bidirectional chat (Manager replies back): closes the conversation loop. The Content Manager's brief generator now also returns an optional response_to_director field in its JSON output. When present, it's posted to platform_role_messages (from_kind='content_manager', to_kind='director', to_role_id=current director's role) AND mirrored as a response_to_director decision row on the CM for audit/timeline consistency. The Director's brief generator picks up the 5 most recent Manager replies under "Manager's replies to you" in its prompt, so subsequent Director briefs engage with the Manager's pushback / questions / confirmations instead of monologuing. AI Pipeline Overview surfaces the bidirectional flow: Director panel shows incoming Manager replies ("Manager →" amber bubbles), Manager panel shows outbound Director replies ("→ Director" emerald bubbles). Full closed loop: Director directives → Manager brief acknowledges → Manager reply → Director's next brief engages → repeat.
  • Phase 4 — Scope toggles + Goals editor + Directives panel (PATCH /role + goal CRUD endpoints + editable ScopePanel + GoalsPanel + DirectivesPanel — mirror of CM Phase 4): Director's autonomous-scope flags + budget now editable from the dashboard. Toggle can_send_to_manager (AI auto-sends its brief's message_to_manager to chat) + can_set_manager_goals (forward-prep; wiring is in place). Set daily_action_budget (0-100 cap). Inline amber safety warning when scope is ON but budget=0. Goals CRUD: super-admin sets platform-level goals like "Ship 12 seasons across 4 genres this quarter" — each active goal flows into the Director's next-brief prompt context. Directives panel: free-form notes ("Focus on diversifying genre mix this month") become note decisions that also flow into the brief. PATCH /role audit-logs scope changes as note decisions so the timeline shows when scope opened up over time.
  • Phase 5 — Human handoff via invite/claim (migration 238 + routes/platform-director-invites.ts + mint/list endpoints on routes/platform-director.ts + /invite/platform-director/[token]/page.tsx public landing + InvitePanel on the dashboard): closes the swap-with-human story for the Director seat. Same shape as CM Phase 5 — one-time token table (platform_director_invites) with rotation semantics + unique active-token partial index. POST /api/admin/platform-director/invites mints; GET /api/platform-director-invites/:token is the unauthenticated landing-page lookup (renders 410 on used/expired); POST /api/platform-director-invites/:token/claim requires an authenticated user (NOT super-admin — anyone with the link), atomically transfers the role via a guarded UPDATE (used_at IS NULL guard prevents double-claim), and audit-logs as a role_transferred decision. Goals + decisions + scope/budget all carry over via FK to role_id. Public landing reframes the role correctly: "you'd be directing the platform" (one layer up from CM's "running the fleet"). InvitePanel on the dashboard mirrors the CM one (Mint form + Active-invite display with copy button + Previously-issued mini-log).

Content Manager (Phase 1) — a swappable job, AI or human

A platform-level role (not a persona flag) — currently held by one entity (AI persona OR real human), swappable over time. Phase 1 lands the data model + read-only dashboard. Later phases bolt on autonomous action: daily advisory brief (Phase 2), scoped autonomous actions within budget (Phase 3), goal tracking + chat (Phase 4), human-handoff invite/claim (Phase 5).

The key invariant: decisions + goals reference the role, not the holder. Swapping AI → human (or vice versa) doesn't rewrite history — the actor at time-of-decision is preserved in content_manager_decisions.by_persona_id / by_user_id, so the audit log + goal progress stay continuous across swaps.

  • Migration 234: three tables
    • content_manager_role — singleton in Phase 1 (title text DEFAULT 'Content Manager' admits multiple rows for forward-compat: "Editor in Chief", "Genre Lead", etc.). Holder column is (held_by_persona_id, held_by_user_id) with a check constraint that exactly one is non-null. Scope flags can_promote / can_scaffold / can_review / can_approve / can_publish all default false; daily_action_budget defaults 0 — Phase 1 is advisory-only even with scope on.
    • content_manager_decisions — every action the role takes/notes, with executed_at (null = advisory), reversed_at (super-admin can revert), auto_executed (Phase 3 cron flag), target_kind/target_id polymorphic into ai_personas + story_seasons.
    • content_manager_goals — super-admin sets goals like "ship 3 seasons by Friday", optional structured progress (progress_metric/progress_value/progress_target) for Phase 4 charts.
  • Backend: services/content-manager.ts exposes getContentManagerSnapshot() (role + hydrated holder + recent decisions + goals), assignContentManager({ personaId|userId, assignedByUserId }) (replaces any existing assignment, audit-logged as a role_transferred decision), releaseContentManager() (vacates seat, audit-logged), and recordManagerDecision() (the shared audit logger every future phase calls). routes/content-manager.ts: GET /api/admin/content-manager, POST /assign (body: { personaId } or { userEmail }), POST /release. Super-admin only via requireSuperAdmin.
  • Frontend: /dashboard/admin/content-manager shows current holder (with PersonaPoseAvatar when AI), scope panel (toggles off but visible — they light up in Phase 3), decisions log, goals panel, and an Assign/Swap/Vacate modal that lets super-admin pick an AI persona (filtered to platform writers) OR enter a human user email. Linked from FeatureMenu as 🎬 Content Manager.
  • Migration apply: supabase db push to land 234 in prod (the routes assume the tables exist). After apply, navigate to the page + Assign.
  • Phase 2 — Daily advisory brief (services/content-manager-brief.ts + cron in apps/backend/src/index.ts + manual trigger POST /api/admin/content-manager/brief + BriefPanel on the dashboard): when the role holder is an AI persona, this service gathers fleet stats (total / platform-writers / unposed), pending review queue (10 most recent), recently-approved seasons (last 5), active goals, and the holder's recent briefs — then asks gpt-4o-mini to write a 200-400 word brief IN THE HOLDER'S VOICE (the system prompt anchors on their MBTI + backstory). The brief lands as a content_manager_decisions row with action_kind='brief' + executed_at=null — advisory only, nothing autonomous yet. Cost ~$0.001/day on gpt-4o-mini. Cron gated by CONTENT_MANAGER_ENABLED=true; manual trigger available via the dashboard's "Generate brief" button (idempotent within 22h unless ?force=1). Heartbeat: content_manager_brief_tick. The BriefPanel on the admin page surfaces the most recent brief prominently with a serif-prose render + "Refresh / Force" controls.
  • Phase 3 — Structured recommendations + Execute/Dismiss controls (services/content-manager-brief.ts JSON-mode upgrade + services/content-manager-executor.ts + POST /decisions/:id/execute + POST /decisions/:id/reverse + RecommendationCard component): the brief generator now uses gpt-4o-mini in JSON mode to return both prose AND 0-3 structured recommendations per brief. Each recommendation has {action_kind, target_id, rationale} where action_kind ∈ (approve, promote, scaffold, review) and target_id is validated against the in-context lists of pending seasons + promotable personas + platform-writer personas (hallucinated UUIDs get silently dropped). Each valid recommendation is inserted as its own content_manager_decisions row with executed_at=null, surfaced on the admin dashboard as a card under the brief with Execute + Dismiss buttons. Execute calls the per-action_kind executor (mirroring the existing per-season endpoints — approve fires the cascade, promote flips is_platform_writer, scaffold runs planPersonaSeason + inserts the season, review marks changes_requested with notes); reverse is the per-action inverse. Every execute stamps executed_at + auto_executed=false.
  • Phase 5 — Human handoff via invite/claim (migration 235 + routes/content-manager-invites.ts + mint/list endpoints on routes/content-manager.ts + /invite/content-manager/[token]/page.tsx public landing + InvitePanel on the dashboard): closes the "swap an AI for a human over time" vision the role schema was built around. New content_manager_invites table holds one-time tokens bound to the role; super-admin mints via POST /api/admin/content-manager/invites (body { rationale?, expires_in_days? }); minting rotates any prior unused invite to keep the unique-active-token index clean. Public landing page at /invite/content-manager/<token> (robots:noindex via layout.tsx) renders the rationale + current holder + "what you'd inherit" (decisions log, goals, scope/budget), with a sign-in-then-claim CTA. POST /api/content-manager-invites/:token/claim requires an authenticated user and does an atomic transfer: guarded UPDATE on the invite (used_at IS NULL) prevents double-claim, then the role's holder columns flip from held_by_persona_id to held_by_user_id, then an audit role_transferred decision lands with the rationale referencing the invite. Decisions + goals carry over via FK to role_id (the role row itself stays the same). 410 Gone on used/expired tokens. Dashboard surface: "Hand off to a human" panel with Mint button + form (note + expiry days), live Active-invite display with one-click copy, and a "Previously-issued" mini-log.
  • Phase 4 — Goals editor + admin directives (goal CRUD endpoints + POST /notes + brief-context inclusion + DirectivesPanel + editable GoalsPanel): admin can now set, edit, retire, and delete goals via the dashboard. Each goal is one row in content_manager_goals with status ∈ (active, met, missed, paused). The brief generator's recent_admin_notes context pulls the 5 most recent admin-authored notes from the decisions log (filtered to action_kind='note' AND by_user_id IS NOT NULL AND not a scope-change audit). New POST /api/admin/content-manager/notes endpoint accepts a free-form body, inserts as a note decision with executed_at stamped immediately. The new DirectivesPanel on the dashboard is a 2-row textarea + "Leave directive" button — anything typed lands as a note that the manager's next brief picks up. Use case: "Focus on mystery this week" / "Push toward Goal X" / "Deprioritize personas without poses" — the kind of thing you'd say to a human content manager in a Monday standup. Routes: POST /goals, PATCH /goals/:id (status + text + target_date + progress fields), DELETE /goals/:id, POST /notes — all super-admin gated.
  • Phase 3b — Autonomous execution within scope + budget (runAutonomousManagerActions() in services/content-manager-executor.ts + PATCH /api/admin/content-manager/role + editable ScopePanel + cron-tick wiring in index.ts): after the daily brief lands new recommendations, the cron loop runs the autonomous executor. For each unexecuted recommendation, it checks whether the role has the matching can_* scope flag AND the daily budget has remaining capacity (counts auto_executed=true decisions in the last 24h). Matching recommendations get executed with auto_executed=true; everything else stays advisory. The persona's user_id (every AI persona has a real auth.users row) is used as the audit attribution for downstream stamps like reviewed_by. Admin controls the scope + budget via the now-editable ScopePanel: toggle any of can_promote / can_scaffold / can_review / can_approve / can_publish + set daily_action_budget (0-100). Saving fires PATCH /role which writes the diff + audit-logs the scope change as a note decision. Safety: budget=0 keeps autonomous mode paused even when scopes are ON (with an in-card warning); the heartbeat metadata surfaces autonomous_attempted / executed / failed / scope_skipped / budget_skipped so the system-health page reflects what actually happened each tick.

Writer Fleet — AI personas as platform writers + real-writer recruitment

A second lane for AI personas: alongside their existing journal/mood/sleep/buzz sim role, promoted personas author multi-chapter seasons on Graphene under their own /writers/[handle] hub. Real human writers can be invited (one-time signed URL) to inherit a persona's voice + body of work + audience. The fleet self-populates the slate; the recruitment flow turns the populated slate into a draw for real writers.

  • Phase 1 — Platform-writer flag. Migration 230 adds is_platform_writer boolean + partial index. Independent of is_active (a persona can sim-write journal entries without being a platform writer, and vice versa). Admin toggle on /dashboard/admin/ai-personas✦ Promote / ✦ Demote button + ✦ Platform Writer chip on every persona row. Backend: PATCH /api/ai-personas/:id/platform-writer.
  • Phase 2a — Plan + scaffold. services/plan-persona-season.ts — one GPT-4o pass anchored on the persona's MBTI + backstory + writing_style + common_topics + last 5 journal entries. Returns {title, premise, genre, setting, chapters: [{episode_number, title, spine}]}. Plot rules in the system prompt: arc must build, no first-word repetition across chapter titles, no shared dominant noun, mix of title shapes. ~$0.02/scaffold. Endpoint: POST /api/ai-personas/:id/scaffold-season (super-admin) writes story_seasons (owner_user_id = persona.user_id, status='draft', review_state='unreviewed') + 9 story_episodes rows (spine in event_description, chapter_prose null).
  • Phase 2b — Persona-voiced prose fill. services/write-persona-chapter.ts — one GPT-4o pass per chapter, position-aware (opening / early / middle / late / final), persona voice samples + season context + chapter spine. ~$0.01/chapter, ~$0.10/season. Endpoint: POST /api/story-seasons/:id/fill-from-scaffold (per-:id ownership middleware) walks every episode with a spine but no prose, parallel-writes via Promise.allSettled (per-chapter failure isolation). Sets triggered=true on success.
  • Phase 2 (approval) — Review pipeline. Migration 231 adds review_state enum (unreviewed/changes_requested/approved) + reviewer/timestamp/notes + partial index for the review queue. Persona seasons start as unreviewed; the publish endpoint (PATCH /api/story-seasons/:id/published) blocks until state moves to approved. Human-authored seasons keep review_state=null and bypass the gate entirely. Component: WriterFleetReviewBar — banner above the chapter list on persona-authored seasons. Admin actions: ✍️ Fill prose (~$0.0N), ↻ Request changes (with notes), ✓ Approve. Self-hides when review_state is null. Review-queue dashboard: /dashboard/admin/writer-fleet — tab filters (All / Pending review / Approved), per-row title/writer/prose-progress/state chip + click-through to /seasons/[id]. Endpoint: GET /api/story-seasons/admin/writer-fleet.
  • Phase 3a — Invite mint + public landing. Migration 232 adds invite_token / invite_minted_at / invite_minted_by / invite_used_at / claimed_by_user_id / claimed_at + unique partial index on invite_token. Mint endpoint: POST /api/ai-personas/:id/mint-invite (super-admin) — generates 24-byte opaque token, returns full URL ${SITE_URL}/invite/persona/${token}. Rotating overwrites prior. Admin UI: 🎫 Invite button on platform-writer rows + modal with the URL + Copy button. Public lookup: GET /api/persona-invites/:token (separate router so it's not under super-admin gate) returns persona profile + up to 12 approved+published seasons. Public landing page: /invite/persona/[token] — robots:noindex, hero + persona profile card (avatar/MBTI/zodiac/backstory/writing style/common topics) + 2:3 poster grid of inheritable seasons + "✨ Claim this voice" CTA routing to /auth/signup with the token in next. 410 Gone state for already-claimed.
  • Phase 3b — Claim/swap. Endpoint: POST /api/persona-invites/:token/claim (authenticated). Atomically: (1) guarded single-row update marks the persona claimed (WHERE invite_used_at IS NULL — prevents concurrent double-claim), (2) username swap (persona's handle → archived_<handle>_<id> placeholder, then claimant's profile.username := persona's handle to satisfy the UNIQUE constraint), (3) profile detail inheritance (full_name + avatar_url + bio when present), (4) season ownership transfer (UPDATE story_seasons SET owner_user_id = claimant.id WHERE owner_user_id = persona.user_id). Journal entries / comments / tips / followers stay on persona.user_id by design — audit trail. Client widget: ClaimWidget.tsx — mounts when ?claim=1 is set, reads supabase session, shows confirmation modal listing what transfers, on success router.push to /writers/[inherited-handle].
  • Phase 4 — Autonomous scaffold cron. services/writer-fleet-cron.ts — hourly tick (gated by WRITER_FLEET_ENABLED=true) picks one eligible platform-writer persona and scaffolds a new season. Two cooldowns: fleet-wide (WRITER_FLEET_MIN_INTERVAL_HOURS=24 default — only one new scaffold per fleet per day) + per-persona (WRITER_FLEET_PERSONA_COOLDOWN_DAYS=7 default — each persona scaffolds at most once a week). Fairest rotation: picks persona whose latest scaffold is oldest (never-scaffolded first). Default cost: ~$0.60/month fleet-wide. Heartbeat: writer_fleet_tick, surfaces on /dashboard/admin/system-health. Cron stops at scaffold — prose-fill / audio / publish remain admin-gated through the approval pipeline so spend ramps deliberately.
  • Scaffold-prompt feedback loop — 3 prior-season signals. All three scaffold call sites (writer-fleet cron + manual POST /api/ai-personas/:id/scaffold-season route + CM autonomous executor) hydrate three best-effort signals from services/season-drop-off.ts and pass them into planPersonaSeason(). (1) Drop-off (getPersonaPriorSeasonDropOff) — finds the persona's most recent prior season with ≥20% reader fall-off at any chapter; prompt block tells the planner to plan the equivalent position in the new arc with an unusually strong hook. (2) Review notes (getPersonaPriorReviewNotes) — the most recent 3 admin review notes (any review_state); prompt block tells the planner to apply these lessons. (3) Performance summary (getPersonaPerformanceSummary) — top-engagement season + most-recent season (scored reads + follows×3); prompt block surfaces this as soft signal, not a directive, so the planner doesn't mechanically repeat the top-performing genre. All three are best-effort — failures swallowed, blocks omitted from the prompt when there's no signal. Each persona's scaffolds get more grounded over time as the feedback loop builds history.
  • Phase 5 — AI personas surface in the public writer directory. AI personas now appear on /writers alongside human writers as soon as one of their seasons reaches is_published_to_graphene=true. Three pieces wire this up: (1) createAIPersona() in services/ai-personas.ts now auto-assigns a slug-form username on the persona's profile via pickAvailableUsername() (display-name → kebab-case, collision-suffixed) so the /api/writers/public filter (which requires username IS NOT NULL) lets persona profiles through. (2) That same /public endpoint now returns an is_ai_persona boolean per writer (computed by checking ai_personas rows for the owner, claimed_at IS NULL); the directory UI renders a 🤖 AI chip on those cards with a tooltip explaining the human-review pipeline. (3) The approve cascade in PATCH /api/story-seasons/:id/review now auto-flips is_published_to_graphene=true (and status: draft → active) for persona-owned seasons — admin "Approve" = ship for the AI writer fleet, mirroring how human writers click their own Publish button. Once a persona is claimed by a human via the invite/swap flow, claimed_at is non-null, the 🤖 chip drops, and the writer reads as fully human. Backfill for existing personas: POST /api/ai-personas/backfill-backstories now also assigns username to any persona profile missing one (returns usernames_assigned count alongside profiles_fixed).
  • Phase 4b — Compress + acceleration mode. Cron defaults lowered for content-build phase: WRITER_FLEET_MIN_INTERVAL_HOURS=4 + WRITER_FLEET_PERSONA_COOLDOWN_DAYS=1 (compress defaults, ~180 scaffolds/month at $3.60). DB-toggled acceleration_mode_enabled flag (migration 249, writer_fleet_settings singleton table) lets admin override to 1h fleet / 0d per-persona without an env var or redeploy — ~24/day, ~720/mo, ~$14.40/mo for scaffolds. Toggled via the "⚡ Acceleration mode" strip on the writer-fleet status panel; cron + status endpoint both read this row at the start of every call and fall back gracefully to env values if the table is missing. Endpoint: POST /api/story-seasons/admin/writer-fleet/acceleration-mode. Single source of truth for cooldowns in constants/writer-fleet.ts so cron + status display can't drift.
  • Phase 5b — Archive proposed seasons. Migration 250 adds story_seasons.archived_at + archived_by_user_id + archived_reason + partial index. Lets admin kill scaffolds that aren't going to ship without changing review_state. Endpoint: PATCH /api/story-seasons/:id/archive (super-admin) toggles archive on/off. The writer-fleet admin endpoint filters out archived by default; ?archived=true returns the Archived lane. Publish endpoint hard-blocks archived seasons. Frontend: per-row ✕ button (instant archive + bottom-right Undo toast, 6s window), 📦 Archived tab with ↻ Restore action, bulk-archive + bulk-approve via multi-select checkbox + sticky selection bar. Per-writer chip row filters the queue to one persona's output (sorted busiest-first). Title-quality health pulse strip counts banned-noun titles in the last 20 seasons and tones emerald/amber/rose so admin sees the prompt fix holding.
  • Title-cliché prompt + retry validator. Scaffold prompt in services/plan-persona-season.ts explicitly bans atmospheric nouns from titles (echoes / reflections / whispers / shadows / fragments / threads / ripples / mirrors) with three concrete-particular examples. Backend regex (BANNED_TITLE_NOUN_REGEX) checks both season title + every chapter title after the LLM responds; one retry fires with explicit "your last attempt used these forbidden nouns: X, Y" context if violations slip through. Worst case +$0.02 per scaffold. Combined with the prompt rule, compliance is near-100%.
  • Approve cascade — fill prose + regenerate poster + auto-publish + generate poses. PATCH /api/story-seasons/:id/review { state: 'approved' } fires four background steps when moving a persona-authored season to approved: (1) fill prose for any empty chapters, (2) regenerate poster if missing/source-panel fallback, (3) auto-publish to Graphene (is_published_to_graphene=true + draft→active), (4) generate pose library for the persona when persona_poses count is 0 — one-time per persona, ~$0.088 at low-quality DALL-E × 8 default kinds. All four are best-effort + async; response returns immediately with cascade_fired: string[] listing what kicked off.
  • Per-persona quality chip on AI Personas list. Each platform-writer row on /dashboard/admin/ai-personas shows N✓ · N? · N📦 (approved / pending / archived). Backed by GET /api/ai-personas/quality-stats (single grouped scan of story_seasons by owner_user_id). Chip flips amber when archive ratio > 40% AND ≥3 archived — "consider retiring this writer or tuning their prompt context." Links to the writer-fleet queue.
  • State-accurate label on /seasons/[id] PublishStrip. Threads review_state into the strip so the top label reads "Approved · awaiting audio render" / "Scaffold · awaiting prose" / "Pending review" / "Changes requested" / "Audio rendering" / "Hidden draft" instead of the generic "Hidden draft" that previously contradicted the writer-fleet queue's "✓ Approved" badge for the same season.
  • Migrations: 230 (is_platform_writer), 231 (review_state + reviewed_by/at/notes), 232 (invite_token + claimed_by_user_id + claimed_at + auxiliary columns), 249 (writer_fleet_settings singleton), 250 (story_seasons.archived_at + companions).
  • Env vars: WRITER_FLEET_ENABLED=true (master switch for the cron); WRITER_FLEET_MIN_INTERVAL_HOURS (default 4 since compress mode, was 24); WRITER_FLEET_PERSONA_COOLDOWN_DAYS (default 1 since compress mode, was 7); AI_PERSONA_AUTO_POSES_ON_CREATE=true (auto-pose-library when a persona is created, in addition to the approve cascade); OPENAI_API_KEY (already required by other features).

Persona Testing — AI personas as UX testers (self-test case study + a service)

AI personas autonomously browse a live site toward a goal and report, first-person, what confused them — a human confirms each finding before it counts ("if the harness can never come out against you, it's broken"). Full design: docs/product/PERSONA_TESTING_SERVICE.md.

  • Shared engine. services/persona-testing-core.tsbrowseAndExtract (reuses persona_browser.autonomous_browse, so its 20-step/25¢ caps + throttle + kill switch apply), a first-person calibrated extractor, normalizeIssues (absence claims from a <4-step browse are capped to low severity + caveated, evidence: encountered|searched_not_found).
  • Self-test (dogfood + promo). services/persona-self-testing.ts runs our own personas over 7 HJ surfaces; findings curated at /dashboard/admin/persona-testing → the public case study /persona-testing (renders only is_public rows). Migration 597 (persona_self_findings). Admin actions: run one / sweep all 7 / →task / →GitHub issue / bulk publish.
  • The service (middle layer). Other site owners request testing on a domain they verify (DNS TXT / meta tag / well-known file — services/persona-test-service.ts, SSRF-guarded intake) then a human approves, then the third-party-hardened runner (services/persona-test-runner.ts — robots.txt respected, PERSONA_TEST_UA, credit debit) executes and delivers. Migration 598 (persona_test_requests / persona_test_domains / persona_test_findings). Report page /persona-report/[token] (noindex, token-gated GET /api/persona-testing/report/:token). Request queue lives in the same admin cockpit (approve / reject / re-run).
  • Monetization — pay-per-use credits. services/persona-test-credits.ts — 1 credit = one persona goal-browse; FREE_MONTHLY_CREDITS=10 granted every month (funnel-first free tier); Stripe packs 25/$9 · 100/$29 · 300/$69 (webhook kind='persona_test_credit_purchase' in routes/payment.ts). Ledger is the source of truth (balance = SUM(delta)), idempotent monthly grant + per-request debit. Migration 599 (persona_test_credit_ledger / persona_test_credit_purchases + notify columns).
  • Delivery — report + Slack/webhook. services/persona-test-notify.ts — on delivery, a Slack team-channel message (Incoming Webhook) or a generic webhook POST; https-only, SSRF-guarded at intake AND send.
  • SEO cluster. /compare/persona-testing (+ [competitor] vs pages) + /best-ai-website-testing-2026; data in lib/persona-testing-compare.ts; registered in compare-registry.ts with a 30-day (monthly) refresh cadence.
  • Routes (mounted /api/persona-testing): public GET /showcase, GET /report/:token, GET /credits/packages; owner-authed POST /request, POST /request/:id/verify, GET /request/:id, GET /requests, GET /credits, POST /credits/checkout; super-admin GET /admin/requests + /approve /reject /run, GET /admin/findings + curation, POST /admin/run /admin/sweep.

Supervised Creators — Junior accounts with publish-review gate + content-safety scan

A sibling lane to the Writer Fleet review pipeline, but for human-supervised creators (e.g. an under-13 account where the platform owner is the publishing gatekeeper). A profiles.requires_publish_review = true flag turns the user's account into a "create freely, publish through admin" mode: they can still build universes, write chapters, and render audio — but the publish action always routes through admin review with a content-safety scan attached.

  • Per-user flag (migration 264): adds profiles.requires_publish_review boolean (default false). Independent of is_creator — a junior account still needs is_creator=true to create at all; this column adds the publish-side restriction on top.
  • Publish gate (apps/backend/src/routes/story-seasons.ts, PATCH /:id/published): when the caller's profile has requires_publish_review=true AND role isn't super_admin, the route hard-rejects with 403 { code: 'PUBLISH_REQUIRES_REVIEW' }. Admin (super_admin) bypasses the gate so they can flip the publish bit themselves after approving.
  • Submit-for-review endpoint (apps/backend/src/routes/story-seasons.ts, POST /:id/submit-for-review): the supervised creator's only path toward publication. Runs the content-safety scan synchronously, writes a row to season_safety_scans, sets review_state = 'unreviewed', clears any prior reviewer audit. Returns the scan summary inline so the user sees the verdict immediately. Restricted to requires_publish_review users — regular creators get 403 and use PATCH /:id/published directly.
  • Content-safety scanner (apps/backend/src/services/content-safety.ts): Claude Haiku 4.5 moderation pass over the season's chapter prose. Two category bands — hard (sexual_content, graphic_violence, hate_speech, drugs_substance_abuse) trips overall severity to 'blocked'; welfare (self_harm, suicidal_ideation, severe_distress) trips to 'flagged'. Returns category counts (moderate+), top-10 flagged excerpts (per-chapter, severity-ranked), and a one-sentence rationale. LLM call uses the existing complete() shim from services/llm.ts. Severity computed twice — once from the model's overall_severity, once recomputed from the per-category counts — and the maximum is persisted as a safety net against a model that mislabels its own verdict. Parse / API failures don't drop the scan: they record a 'flagged' row with a parse-failure note so the admin reviews manually instead of getting an absent-scan = "safe" misread.
  • Scan storage (migration 265): season_safety_scans table — append-only, one row per scan. Columns: season_id, scanned_at, model_key, severity ('safe'|'flagged'|'blocked'), categories jsonb, flagged_excerpts jsonb, notes text, latency_ms, cost_cents. Partial index (severity, scanned_at DESC) WHERE severity IN ('flagged', 'blocked') for any future "every flagged scan, newest first" admin view.
  • Quick Review modal panel (components/seasons/QuickReviewModal.tsx): the existing admin triage modal now renders a SafetyScanPanel between the chapter-1 preview and the action row when safety_scan is present on the row. Severity-coded background (rose/blocked, amber/flagged, emerald/safe), per-category chips (hard categories rendered first), top-10 flagged excerpts shown inline as Ch.N · Category (severity) followed by an italic ≤200-char excerpt. Surfaces in both /graphene (admin filter row) and /dashboard/admin/writer-fleet since both pages mount the same modal. The manageable-version-status endpoint (routes/story-seasons.ts) batch-joins the latest scan per awaiting season via getLatestSafetyScansForSeasons() to avoid an N+1.
  • Configuring a specific user: a separate, non-migration SQL file at supabase/migrations/CONFIGURE_WOLFSARECOOL.sql documents the pattern for flipping the flag for a specific signup. Replace the email + run against prod manually after migrations 264 + 265 are applied.
  • What's deferred (Phase 2): out-of-band welfare-signal routing (email / dashboard banner when self-harm / suicidal-ideation flags fire — currently those just appear in the queue like any other flag); per-chapter auto-scan on save with writer-side warning UI; real COPPA primitives (no age column / parental consent / supervised-account linkage exists in the schema today, so this flow assumes admin-level oversight outside the platform).

Story Seasons — Serialized Narrative Entertainment

Shared storylines (murder mysteries, dramas, thrillers) that run across groups of AI personas. Each persona gets a role (guilty, suspect, witness, investigator, victim, bystander) with secret knowledge and a character arc. Episodes trigger sequentially and inject events into all cast members' journal entries — the guilty persona's anxiety leaks through, investigators theorize, witnesses hint at what they saw.

  • Service: apps/backend/src/services/story-seasons.tsgenerateSeasonPlan() uses GPT to assign roles, secret knowledge, character arcs, and plan episode outlines. triggerEpisode() advances the story. getActiveSeasonContext() injects season context into journal entry prompts based on persona's role.
  • Routes: apps/backend/src/routes/story-seasons.tsGET /api/story-seasons, GET /api/story-seasons/:id (with episodes + cast), POST /api/story-seasons (GPT plans full season), POST /api/story-seasons/:id/trigger-episode, POST /api/story-seasons/:id/complete.
  • Admin UI: /dashboard/admin/seasons — create season form (title, genre, premise, setting, episode count, multi-select cast), season cards with trigger buttons.
  • Public audience page: /seasons/[id] — cinematic dark theme, cast grid with role badges linking to character journals, episode timeline, CTA.
  • Cross-season cast (universe view): /universes/[key] has a "Cast across the universe" section — the personas cast in that universe's published seasons, recurring ones (≥2 shows) first, each showing the season posters they appear in + an "In N shows" badge, linking to their cross-season /actors/[userId] page. Aggregated in GET /api/story-seasons/universes/:key/public (the cast field) from story_cast × ai_personas over the universe's seasons — story_cast.role is omitted from the public payload (it leaks whodunnit: guilty/victim/suspect), same whitelist discipline as the canon fields. No new schema — story_cast (season_id, persona_id) already lets one persona recur across seasons.
  • Integration: journal entry generator checks story_cast for active seasons and injects role-specific context. Guilty personas get "subtle anxiety leaks into your writing." Investigators get "note your theories."
  • Immersive timeline reader: /seasons/[id]/read — ultra-dark (#060610), no chrome, entries grouped by date with color-coded left borders per role (red=guilty, amber=suspect, emerald=investigator, cyan=witness). Character name colors, filter pills, "recovered journals" preamble. Designed to feel like opening a dead person's journal.
  • Timeline API: GET /api/story-seasons/:id/timeline — all cast members' journal entries chronologically since season creation.
  • 16 built-in presets across 6 genres (mystery, thriller, drama, comedy, romance, scifi). Highlights: Departure (sailboat, 12 ep), Station 11 (arctic, 10 ep), Floor 7 (apartment, 8 ep), The Turing Logs (AI discovers it's not real, 10 ep), Clearance (bureaucratic existential crisis, 12 ep), The Wake-Up Call (shared dream, 8 ep).
  • Migration: 093_story_seasons.sqlstory_seasons, story_episodes, story_cast tables with RLS (public reads active seasons + triggered episodes, admins manage all).
  • Frontend routes: /dashboard/admin/seasons, /seasons/[id], /seasons/[id]/read

Why Files Mix-In Voting (June 2026)

Lets the audience vote on which Why Files episode (the real-world-mystery YouTube channel) should be referenced in the upcoming episode of a universe show (Octo World, E42, Grey Loch, Turing Logs, …). An admin pairs a Why Files episode with a show's next un-triggered episode; logged-in readers upvote pairings on the public universe page; the top-voted pairing surfaces to the author as a planning note only (no automatic prompt injection — the author decides how/whether to weave the real-world mystery in). Sits on the Odessa fiction→real-change loop.

  • Migration: 434_why_files_mixin.sqlwhy_files_episodes (catalog), mix_in_suggestions (pairing: Why Files episode → season + target_episode_number, status open/accepted/dismissed, denormalized vote_count), mix_in_suggestion_votes (one upvote per user; RLS mirrors dream_video_upvotes from migration 060).
  • Service: apps/backend/src/services/mix-in-suggestions.tslistCatalog(), listSuggestionsForUniverse(key, viewerUserId?), getSuggestionsForSeason(), listShowsForPairing() (shows + next upcoming episode), listAllSuggestions(), createSuggestion(), updateSuggestionStatus(), toggleVote() (vote tally maintained in-route like the dreampro upvote handler).
  • Routes: apps/backend/src/routes/mix-ins.ts, mounted /api/mix-ins — public: GET /universe/:key (optional auth → viewer_voted), POST /:id/vote; author aid: GET /season/:seasonId; admin (super_admin/creator): GET /catalog, PATCH /catalog/:id (attach YouTube URL → oEmbed thumbnail), GET /shows, GET /suggestions, POST /, PATCH /:id, POST /generate-show (spin up a whole new draft show in a universe seeded by a Why Files episode — generateShowFromWhyFiles() turns the episode into a steering hint for autoGenerateShowInUniverse() in auto-show-in-universe.ts, returns the new season_id, and stamps story_seasons.source_why_files_episode_id (migration 436) for provenance), GET /generated-shows (lists shows built from a Why Files episode — surfaced as a "Shows built from a Why Files episode" section on the admin tool).
  • Public voting row: apps/frontend/src/components/seasons/MixInVotingRow.tsx, embedded in /universes/[key] (hidden when no open pairings).
  • Admin tool: /dashboard/admin/mix-ins — create pairing, tally board (accept/dismiss/reopen), catalog manager.
  • Author aid: apps/frontend/src/components/seasons/MixInPlanningAid.tsx on the creator show-detail panel in /dashboard/universes.
  • Seed: apps/backend/scripts/seed-why-files-episodes.ts — reads an IMDb list CSV export (scripts/data/why-files-episodes.csv, real titles/ratings/tconsts) if present, else a starter JSON of well-known topics. Run cd apps/backend && npx tsx scripts/seed-why-files-episodes.ts --apply.

Story Seasons — Media Pipeline (YouTube + Podcast)

End-to-end production pipeline that turns a completed Story Season into a podcast episode + YouTube video. Six stages, each independently triggerable from the admin page or auto-run when a season completes.

  • Stage 1 (visuals): apps/backend/src/services/season-media.tsgenerateSeasonVisuals(seasonId) uses DALL-E 3 HD to render a genre-tuned 2:3 movie poster + cinematic 1:1 portraits per cast member. Posters and portraits are role-styled (guilty=unsettling, victim=wistful, etc.). Concurrency limited to 3 to respect OpenAI rate limits.
  • Visual versioning + auto-baseline: every poster / portrait / trailer regen records a row in story_visual_versions (migration 127) so admins can browse + restore prior renders via VisualVersionsModal. Catch: seasons that got their FIRST poster before versioning existed (or before the auto-baseline shipped) had no row capturing the original — when an admin clicked "Regen poster only," the original URL was overwritten and lost from the modal. Fix: ensureBaselineVersion is now called at the top of every regen path (generateSeasonVisuals, regenerateSeasonPoster, renderSeasonTrailer); idempotent — no-op for assets that already have history, captures the current scalar URL as a baseline row otherwise. For already-pinned-but-never-regen'd assets, the Versions modal exposes a 📌 Capture current as baseline button that hits POST /:id/visual-versions/backfill-baseline (super-admin) — captures whatever the season is currently pinned to so the next regen doesn't lose it.
  • Stage 2 (voices): apps/backend/src/services/season-voices.tsassignVoicesToCast() picks ElevenLabs voices from a curated bank of 20 default voices (works on any plan). Buckets by gender (from backstory.gender or name heuristic), assigns via stable hash so re-runs are deterministic, avoids dupes within the same season. Narrator defaults to "Daniel" (deep British). Manual overrides via PATCH /cast/:castId/voice and PATCH /:id/narrator.
  • Stage 3 (script): apps/backend/src/services/season-script.tsgenerateSeasonScript() calls GPT-4o-mini once for opening narration, per-episode intros, transition phrases, and outro. Buckets entries into episodes by created_at relative to season range. Builds season_audio_segments rows in order: opening → ep_intro → (persona_intro → journal_entry → transition)* → outro. Tone direction: "Ira Glass meets Errol Morris meets true-crime documentary."
  • Stage 4 (audio): apps/backend/src/services/season-audio.tsrenderSeasonAudio() calls ElevenLabs eleven_multilingual_v2 per segment, ffprobes duration, uploads each MP3 to season-assets/audio/{seasonId}/segments/, then ffmpeg -f concat into compiled.mp3 with -c copy. Long-running (~5+ min for a 75-min season). Requires ELEVENLABS_API_KEY env var.
  • Stage 4.5 (music): apps/backend/src/services/season-music.tsscoreSeasonMusic() runs four sub-stages: (a) mood analysis — GPT tags each segment with mood ∈ {tense,melancholy,ominous,climactic,eerie,warm,reflective,hopeful}, intensity 0-10, pace ∈ {slow,medium,fast}, episode-arc-aware so later episodes lean climactic; (b) clustering — adjacent same-mood NARRATOR segments group into cues (journal_entry / persona_intro segments are excluded — they get leitmotifs instead), persisted to season_music_cues; (c) leitmotif generationgenerateCharacterThemes() calls GPT to design each cast member's musical signature (lead instrument, key, BPM) tied to their MBTI + role + season genre, then MusicGen creates a 15s motif (e.g., guilty cellist for ISTJ, hopeful flute for ENFP, mysterious violin for INFJ); themes are cached on season_character_themes and reused across re-runs; (d) per-segment mixbuildPerSegmentMusicBed() walks every segment and picks its music source: character motif for journal_entry/persona_intro segments (looped to fit), cluster cue otherwise; per-segment afade in/out provides implicit crossfades. Final ffmpeg sidechain-compress ducks music ~12dB when voice is present.
  • Stage 5 (video): apps/backend/src/services/season-video.tsrenderSeasonVideo() builds 1920×1080 30fps MP4 with Ken Burns slideshow. Picks the right image per segment (poster for narrator, portrait for journal entries), generates per-segment clips via ffmpeg zoompan filter (slow 1.0× → 1.4× zoom-in), concatenates clips, muxes the compiled audio. Output written to season-assets/video/{seasonId}/compiled.mp4 with +faststart for streaming.
  • Distribution: apps/backend/src/services/season-distribution.tsbuildPodcastRss() produces iTunes-compatible RSS XML at GET /api/podcast/rss (public). Each season with podcast_published_at set appears as an <item> with poster as <itunes:image>. Per-season show notes (migration 247): the <description> prefers season_publishings.show_notes (a GPT-4o-generated string with synopsis + Cast + Runtime + Content warnings) when present, falls back to premise+setting otherwise. Generated via POST /api/story-seasons/admin/:id/generate-show-notes (super-admin, ~$0.01) which hydrates inputs from the season + chapters + audio durations + author display name and calls services/plan-season-show-notes.ts. Trailer support (migration 248): story_seasons.is_trailer = true flips the item's <itunes:episodeType> to trailer (Apple pins these at the top of the channel); chapter items always emit full. uploadToYouTube() streams compiled MP4 via googleapis youtube.videos.insert (refresh-token OAuth flow), defaults to UNLISTED so you can preview before publishing. Custom thumbnail (migration 438): after the insert it calls ensureYouTubeThumbnail() (season-youtube-thumbnail.ts) → youtube.thumbnails.set with a purpose-built 1280×720 landscape thumbnail (the 1024×1792 portrait poster crops badly). The thumbnail is composited via the poster-compose HTML→Chromium pipeline (renderHtmlToPng): the poster fills the frame blurred+darkened as a background, the crisp portrait poster sits on the right, title + genre eyebrow + brand line on the left; URL cached on story_seasons.youtube_thumbnail_url. Best-effort — a 403 (channel not verified for custom thumbnails) or missing source art just logs and lets YouTube auto-pick. Admin GET/POST /api/story-seasons/:id/youtube-thumbnail (?force=1) pre-generates + previews it.
  • Per-show RSS feeds + per-show iTunes metadata (migration 266): the original network feed at /api/podcast/rss mixed all chapters from all shows into one Apple Podcast called "Graphene." This worked for cross-show discovery but blocked per-show category ranking. Migration 266 adds story_seasons.itunes_subtitle, itunes_category, itunes_subcategory, itunes_keywords — nullable, falling back to network defaults from constants/graphene.ts when unset. buildPodcastRss(siteUrl, { seasonId }) returns a per-show feed: channel-level metadata is the show's own iTunes fields (or fallback); items are just this show's chapters. New public route GET /api/podcast/rss/show/:seasonId exposes it — submit each show separately to Apple Podcasts Connect for independent genre-search ranking. The network feed at /api/podcast/rss is unchanged so existing subscribers stay valid. iTunes keywords concatenate per-show extras with the network defaults (audio drama, serialized fiction, fiction podcast, interactive podcast, listener-driven story, weekly chapters, Graphene). Per-show recommended values: docs/marketing/2026-05-19-graphene-apple-podcasts-aso-sweep.md. Per-show submission tracker (added 2026-05-24 follow-up): migration 292 adds two scalar columns on story_seasonsapple_podcasts_submitted_at + spotify_submitted_at. Backend routes GET /api/admin/show-distribution (list published shows + per-platform pending counts) and POST /api/admin/show-distribution/:seasonId (body { platform: 'apple'|'spotify', submitted: bool }, sets/clears the timestamp). UI extends /dashboard/admin/podcast-feed with a PerShowSubmissionTracker section: pending-count chips (clicking opens Apple/Spotify portals in new tab), per-show row with copy-RSS-URL button + Apple/Spotify toggle pills (✓ green when submitted with date stamp, ○ neutral when pending). Defaults to showing only shows still pending on at least one platform; toggle shows all. Why scalars + manual tracking instead of API polling: both submit portals are human-gated (captcha + manual review), so we can't auto-detect submission state; manual toggle is the source of truth. Why two scalars instead of submitted_platforms jsonb: keeps the "X pending Apple" count queryable without a jsonb index. Add Pocket Casts / Overcast / etc. as additional scalars if we track them later.
  • Pipeline orchestrator: apps/backend/src/services/season-pipeline.tsrunFullPipeline() runs all six stages with per-stage error handling (later stages still attempt if earlier ones fail). Skips already-completed stages (idempotent re-runs). Triggered automatically by POST /:id/complete when season.auto_publish=true, or explicitly via POST /:id/run-pipeline.
  • Migration: 094_season_media_assets.sql — adds poster_url, narrator_voice_id, assets_generated_at, auto_publish to story_seasons; portrait_url, voice_id, voice_name to story_cast; season_audio_segments (ordered TTS script); season_publishings (audio_url, video_url, youtube_url, podcast_published_at, status).
  • Routes: /api/story-seasons/:id/{generate-visuals,assign-voices,generate-script,render-audio,render-video,publish-podcast,unpublish-podcast,upload-youtube,run-pipeline}, PATCH /:id/auto-publish, GET /:id/script, GET /:id/publishing, public GET /api/podcast/rss.
  • Admin UI buttons (per season): 🎨 Visuals · 🎤 Cast Voices · ✏️ Edit Voices (modal) · 📝 Script (modal w/ preview) · 🎙️ Render Audio · 🎬 Render Video · 🎧 Podcast (publish toggle) · ▶️ YouTube (unlisted upload). Plus "Auto-publish on complete" checkbox per row.
  • Setup guide: docs/setup-guides/SEASON_MEDIA_PIPELINE.md — covers storage bucket creation, ElevenLabs API key, YouTube OAuth refresh-token flow, pre-launch checklist, Apple/Spotify/Pocket Casts submission walkthroughs, and per-platform failure modes.
  • Cost: ~$0.88 (DALL-E) + ~$25 ElevenLabs (75-min season on Creator plan) per produced season. ffmpeg + RSS + YouTube are free.
  • Distribution work tracked in product_tasks under category Podcast Distribution — covers cover art design, show description, trailer episode, per-platform submission, YouTube channel branding, and post-launch enhancements (custom thumbnails, end screens, public /podcast page, real enclosure byte length in RSS). View at /dashboard/admin/tasks.

Graphene — Network Branding + Subscription

All Story Seasons + their media pipeline live under the Graphene FM network identity. Single source of truth in apps/backend/src/constants/graphene.ts (mirrored on frontend at apps/frontend/src/lib/graphene.ts) — flows through to RSS feed metadata, YouTube descriptions, and the public landing page.

  • Brand naming convention (PR #507): canonical display name is "Graphene FM" (Title Case). The GRAPHENE.name constant stores Title Case; CSS handles ALL-CAPS treatment on brand pills (uppercase tracking-[0.3em] etc.) without locking into uppercase in plain-text contexts (page titles, email subjects, RSS itunes:author). Sub-brands stay independent: GRAPHENE.plusName = 'Graphene+' is its own constant so sites previously rendering ${name}+ don't become "Graphene FM+". (The creator studio, formerly the "Graphene Studio" sub-brand, was spun out into its own standalone brand EmberKiln — see project_emberkiln_rebrand; it is no longer a Graphene sub-brand.) Body prose in flowing paragraphs uses "Graphene" alone for readability — "FM" mid-sentence reads as marketing-speak. The current tagline is "Written. Spoken. Yours." (reverted from a May experiment with "It will not be televised" — see #508).

  • Spotify network show URL in GRAPHENE.spotifyUrl defaults to /show/033mCwDIsxHpvUtyZBdMFd (the show page, not an episode) so the "Listen on Spotify" badge lands listeners on the full network feed. Overridable via NEXT_PUBLIC_GRAPHENE_SPOTIFY_URL in Vercel.

  • Public landing: /graphene — hero, slate (poster grid of active + completed seasons), how-it-works trio (read / listen / watch), subscribe CTA, RSS link.

  • Sleep timer on the chapter playback pill (components/seasons/ChapterPlaybackPill.tsx): the fixed-bottom now-playing pill that follows readers down the /seasons/[id]/read (and /timeline) page now has a 💤 button that opens a 15 / 30 / 45 / 60-minute menu. Timer is wall-clock based (sleepUntil = Date.now() + min * 60_000) so backgrounded tabs and locked phones still pause on time. While running, the button collapses to 💤 23m so the remaining time is always visible; tapping again offers a Cancel-timer item. Session-only state — not persisted across page loads. Pause is fired by calling el.pause() on the active chapter <audio> element, so the existing pause-event listener flows the rest of the UI state through (pill flips to "paused", etc.) without extra wiring.

  • Analytics discoverability (follow-up): the public /seasons/[id] page surfaces an inline "📊 Analytics" link alongside the follow button when currentUserId === season.owner_user_id. The creator-onboarding endpoint (GET /api/creator/payouts/onboarding-status in routes/creator-payouts.ts) now also returns a seasons[] array (id / title / poster_url / publish state / drift state) — /dashboard/creator renders this as a "Your shows · N" grid where each card has a one-tap 📊 Analytics link plus a Public page → link. Self-hides when the creator has no shows yet (the checklist's step 4 already prompts that case).

  • Per-show writer analytics (services/season-writer-analytics.ts + route in routes/story-seasons.ts + page at /dashboard/seasons/[id]/analytics): owner-only rollup of every signal that matters for a specific show. Backend service runs all queries in parallel: followers (total + 30d + 5 most recent), readers (distinct + 30d active from user_season_progress), chapters (total / with_audio / notified), and creator_earnings (lifetime gross + creator-share + last-30d-creator + grouped by source_kind). Interleaved recent_activity[] mixes follows, reader-progress updates, and earning entries newest-first (top 25). Ownership enforced inside the service — returns null if owner_user_id doesn't match the caller. Route: GET /api/story-seasons/:id/writer-analytics (auth, 404 on miss). Frontend: 4 headline tone-coded stat cards (followers / readers / chapters / 30d-to-you with hint sublines), an earnings-by-source grid, a most-recent-followers list (with display names hydrated from profiles), and an activity feed with kind-coded emoji prefixes (+/📖/💸). Cross-links to /dashboard/creator and /dashboard/creator/earnings at the foot. Per-chapter LISTENING table (added 2026-05-24 follow-up — Phase 0 KPI for "which chapters do listeners finish vs bounce"): distinct from the existing chapter-completion bars which track READING via user_season_progress; this tracks LISTENING via chapter_listen_sessions (migration 121 — one row per listener-episode-take combo with max_progress_seconds + completed (≥90%) + play_count). New listening section on the writer-analytics payload: distinct_listeners_total, active_30d, per_chapter: [{ episode_number, title, listeners, completed, completion_pct, replays }], plus biggest_drop_after / biggest_drop_pct for the listen-side equivalent of the existing read-drop callout. New buildListeningRollup(seasonId, since) helper in season-writer-analytics.ts — one row-fetch of listen sessions + in-memory aggregate (per-season volume is bounded since one row per listener-chapter-take combo, not per play event; no need to push aggregation into Postgres). UI: new table below the read-completion bars with columns Ch / Title / Listeners (with −N delta to next chapter) / Completed / % finished (color-coded: ≥70 green, ≥40 amber, <40 rose) / Replays. Biggest-drop row highlighted in rose tint with a callout above when the drop is ≥25%. Hidden entirely when zero listens recorded — silence is fine but rendering an empty grid is noise. Why this is more useful than the read-drop chart for the audio-fiction product: most Graphene listeners hit the audio player, not the prose reader; reading drop-off systematically under-counts. The listening table is the primary Phase 0 retention signal.

  • One-click unsubscribe + List-Unsubscribe header (migration 220): every season_follows row carries an unsubscribe_token (24-char base64url, server-minted at follow time; backfilled for legacy rows via gen_random_bytes(18)). Unique index season_follows_unsubscribe_token_unique makes the token lookup O(1). The chapter notification email includes (1) an inline footer "Unsubscribe with one click" link to /unsubscribe/season?token=X and (2) a List-Unsubscribe RFC 8058 header so Gmail / Apple Mail surface a one-click button next to the sender name. Route: POST /api/season-follows/unsubscribe (public, token-only) calls unfollowByToken which deletes the matching row. Frontend confirmation page: /unsubscribe/season — three states (pending / ok / invalid), wrapped in Suspense for the useSearchParams hook. Bad/expired tokens render the "already unsubscribed" state silently (no scary errors for recipients clicking old emails). Re-follow stays one-tap from the show page; the token doesn't churn on quick unfollow/refollow toggles because follow() checks for existing rows first.

  • Email-on-new-chapter (migration 219 + services/chapter-notifications.ts): hourly heartbeat-tracked cron (chapter_followers_notify_tick in index.ts) finds story_episodes rows where chapter_audio_url IS NOT NULL AND followers_notified_at IS NULL, joined against published seasons (is_published_to_graphene = true), fans out a Graphene-branded Resend email to every follower of the parent show, then stamps followers_notified_at so the chapter is never re-notified. Bounded at 10 chapters per tick so a backfill can't burst the Resend rate limit. Migration 219 adds the column plus a partial index (season_id) WHERE chapter_audio_url IS NOT NULL AND followers_notified_at IS NULL so the cron's lookup is O(pending). Unpublished seasons get their column stamped too (skipping the email) so the cron doesn't keep scanning them forever — admin nullifies the column to re-fire if a season transitions from private to public. Per-recipient retry is not built in (the column is "we attempted a fan-out," not "this recipient successfully received"); v2 if real bounces show up.

  • Email-on-new-show for writer follows (migration 224 + services/writer-publish-notifications.ts): hourly heartbeat-tracked cron (writer_publish_notify_tick in index.ts) finds story_seasons rows where is_published_to_graphene = true AND writer_followers_notified_at IS NULL, hydrates the writer display name (pseudonym → name fallback), fans out a Graphene-branded Resend email per follower, then stamps the column so re-runs are no-ops. Bounded at 5 seasons per tick. Migration 224 adds the column + a partial index (owner_user_id) WHERE is_published_to_graphene = true AND writer_followers_notified_at IS NULL so the cron's lookup is O(pending). Empty-followers seasons get stamped too (new followers AFTER stamp won't get backfilled — intentional, they'll get future shows). Each email carries a List-Unsubscribe RFC 8058 header AND an inline footer link to /unsubscribe/writer?token=X using the per-row unsubscribe_token minted at follow time; the public confirmation page mirrors /unsubscribe/season shape (three states, silent fallback for stale tokens).

  • Writer follows (migration 223 + services/writer-follows.ts + routes/writer-follows.ts mounted at /api/writer-follows + WriterFollowButton.tsx): sibling to season_follows — "follow the person across all their work" rather than "follow this specific show." Schema mirrors season_follows: PK (follower_user_id, writer_user_id), unsubscribe_token minted server-side at insert via gen_random_bytes(18) base64url-encoded inline in the DEFAULT, CHECK constraint preventing self-follows. Three indexes: follower-recency, writer-id for follower counts + future notification cron, unique unsubscribe_token. Routes: POST /:handle, DELETE /:handle, GET /:handle/count (public), GET /:handle/status (auth, combined {following, follower_count}), POST /unsubscribe (public, token-based for future emails). followWriter() checks for existing row first so re-follow doesn't churn the token. Handle resolution shared with the writers route — accepts either UUID or username slug. Frontend: WriterFollowButton mounted on /writers/[handle] just below the username line. Anonymous taps route to /auth/signup?redirect= back to the hub. Public follower count visible alongside. Notification cron (writer publishes a new season → email followers) is a future ship; the schema's token + indexes are already shaped to support it.

  • "More from this writer" cross-promotion strip (MoreFromWriterStrip.tsx): mounted on /seasons/[id] (in SeasonClient.tsx just above the existing platform-wide OtherShowsStrip) when the show has a public-handled owner. Fetches GET /api/writers/:handle (no new endpoint), filters out the currently-viewed season, renders the writer's remaining published catalog as a 6-up poster grid with title + genre overlay. Self-hides when the owner has no other published shows. "+ N more in their catalog" footer appears when the writer has more than 6.

  • Playback speed control on /seasons/[id] — explicit 0.75× / 1× / 1.25× / 1.5× / 2× buttons mounted under the audio player in SeasonClient.tsx. Persists across sessions and seasons under hj:audio-playback-rate:v1 so a listener who prefers 1.25× on one show gets 1.25× on the next.

  • Season follows (migration 218 + services/season-follows.ts + routes/season-follows.ts mounted at /api/season-follows + SeasonFollowButton.tsx): listener "follow" relationship on a season — distinct from progress (which only tracks people actively reading). One row per (user_id, season_id) enforced by PK so double-clicks can't duplicate. Two indexes: (user_id, followed_at DESC) for "all my follows newest first," (season_id) for follower-count lookups + per-show writer analytics. Routes: POST /:id (follow), DELETE /:id (unfollow), GET /:id/count (public follower count), GET /:id/status (auth — combined {following, follower_count} in one round-trip), GET / (auth, list user's follows newest-first). RLS: owners-only for writes; public SELECT for follower counts. Frontend: SeasonFollowButton mounted on /seasons/[id] just below the premise text — anonymous taps route to /auth/signup?redirect= back to the show; signed-in taps toggle optimistically (reverts on API failure). Two variants: default (pill + count label) and compact (pill only, for tighter surfaces). /dashboard/listening now has a "Following · N" row above the recency-grouped history showing followed shows as a poster grid.

  • Cross-device progress visible everywhere (follow-up ship): ContinueListeningRow on /graphene now probes supabase.auth.getSession(); when authed, it fetches /api/listening/progress and merges with the localStorage scan (server wins ties via updated_at >= saved_at). ResumeReadingPill falls back to GET /api/listening/progress/:season_id when localStorage is empty on this device — so a show started on phone instantly surfaces the resume pill on laptop with zero scrolling required. Both surfaces stay correct for anonymous users (localStorage-only path).

  • Cross-device listening progress (migration 217 + services/listening-progress.ts + routes/listening-progress.ts mounted at /api/listening + ResumeReadingPill.tsx): the per-paragraph state ResumeReadingPill writes to localStorage is now mirrored to a new user_season_progress table (PK (user_id, season_id); one row per user per season — upsert on every write). Authed pill writes server-side every 30s + on visibilitychange=hidden + on pagehide. Service validates + clamps inputs (episode/paragraph as non-negative ints; audio_currenttime_seconds capped at 24h; path_key ≤ 16 chars) so bad client state can't break the page. Routes: GET /progress (list user's seasons newest-first, cap 200), GET /progress/:season_id, POST /progress (upsert), DELETE /progress/:season_id. Owner-only RLS. Anonymous readers still use the localStorage-only path. /dashboard/listening merges server progress with localStorage (server wins ties; per-season source: 'server' | 'local' tracked) so signed-in users see cross-device history while still picking up local-only entries from this device.

  • /dashboard/listening — personal listening history page (apps/frontend/src/app/dashboard/listening/page.tsx): permanent personal listing of every Graphene show with an in-progress position on the current device. Reads hj_read_position_<seasonId> keys from localStorage (same source as the /graphene Continue-listening row), fetches the public slate from GET /api/story-seasons/public, matches by id. Groups results by recency (Today / This week / This month / Earlier — groupByRecency helper). Self-hides shows whose ids no longer exist in the public slate so dangling references don't render. Each card links to /seasons/[id]/read. Empty state surfaces a "pick a show →" CTA pointing at /graphene. Device-local — cross-device progress is flagged as a future ship in the page copy + acknowledged in the route comment. Nav entry "My listening" added under Stories in FeatureMenu.tsx.

  • Multi-voice chapter audio takes — listener picker + on-demand admin renders (migration 276 + services/episode-audio-takes.ts + ChapterVoicePicker + ChapterPlaylistPlayer): story_episodes.chapter_audio_url is single-valued — a re-render with a different narrator destroys the prior rendition. The new episode_audio_takes table holds ALTERNATE-voice renditions of the same chapter prose, non-destructively. Listeners get a 🎙 picker pill in the player chrome listing the default + every ready alternate; their preference persists per-season in localStorage (hj_voice_pref:<seasonId>) and applies across chapters with graceful fallback to the default when the preferred voice isn't rendered for the current chapter. Voice swap mid-chapter preserves currentTime (capture-then-restore via voiceSwapResumePosRef in the onLoadedMetadata handler) and resumes playback if it was playing. Super-admins get a "+ Render in another voice" sub-menu listing VOICE_BANK voices, kicks off a queued render via POST /api/story-seasons/:id/episodes/:n/audio-takes/render; the queued/rendering/failed status surfaces inline in the picker. Render worker reuses season_audio_segments text (already chunked + heading-stripped) but renders each segment in the target voice — no cross-voice segment cache (audio bytes are voice-specific). Unique (episode_id, voice_id) means re-rendering the same voice REPLACES the prior row, so admins iterate without leaking ghost takes. RLS: public reads ready takes on visible seasons; super-admin manages all. Karaoke alignment for alternate voices: segment_timings jsonb records per-segment durations, but ReadClient's karaoke highlight still drives off the primary voice's segment timings (deferred wiring).

  • Per-show paid-traffic landing — /listen/[id] (apps/frontend/src/app/listen/[id]/page.tsx + admin helper PaidTrafficUrlPanel): sibling route to /listen (the catalog-style landing) that locks the surface to ONE specific show via the URL — paid-acq kit says show-specific converts ~2× catalog. Reuses ListenClient so play / listen-tracking / sticky-subscribe-after-30s mechanics stay identical; differs from /listen in three ways: (1) URL is canonical /listen/{id} instead of ?show=<uuid> (cleaner for ads-manager pastes), (2) generateMetadata scopes title/description/og:image to THIS show so social-share previews render the show's poster instead of the generic Graphene mark, (3) 404 when the season doesn't exist instead of falling back to a different show (ad campaigns expect the URL to land on the intended show). PaidTrafficUrlPanel is a super-admin-only collapsible block on the season detail page that auto-generates per-source URLs (Meta / Reddit / TikTok / Google / Apple / Pinterest) with UTM params pre-filled — one-click copy for ad-creative builds. Both routes are robots noindex since the canonical organic listing remains /seasons/[id].

  • Pinterest Tag (components/analytics/PinterestPixel.tsx + trackPinterestEvent in lib/analytics.ts): mirror of the FB / Reddit / TikTok pixel pattern, gated on NEXT_PUBLIC_PINTEREST_TAG_ID. Why: Pinterest's demo skews women planning life events (weddings, memorials, baby showers, milestones) — best-fit channel for Lovio per its marketing kit. Listen-tracking fans events to Pinterest too: WatchVideo on listen_started, Lead on the 30s milestone, Signup (value 1.0 USD) on the 90s qualified-lead milestone. trackSignUp fires Pinterest Signup; trackPurchase fires Pinterest Checkout. Mount sits in layout.tsx next to TikTokPixel.

  • Listen-tracking fan-out for paid-acquisition retargeting (lib/listen-tracking.ts): one module the whole site routes audio-play events through, so PostHog + GA + Meta + Reddit + TikTok + Pinterest all see the same listen_started / listen_milestone_30s / listen_milestone_90s signals from every player surface — paid-traffic /listen, single-track /seasons/[id] via SeasonClient.tsx's attachListenTracking effect, and novel-mode chapter playlists via ChapterPlaylistPlayer. Session state (started + cumulative-listened-seconds + milestone fired flags) lives in sessionStorage under graphene_listen_session_v2 so the events fire ONCE per visit even if the listener jumps shows mid-session (preserves lookalike-pool quality). Cumulative seconds are computed from positive deltas only — forward seeks don't pad the counter (clamped to [0, 5] per tick). The 90s milestone fires the high-value Subscribe event to TikTok + value: 1.0 USD to Meta — the "qualified lead" signal for paid-ads optimization. Unblocks the Meta retargeting audience Visitors_listened_no_subscribe from docs/marketing/2026-05-20-graphene-paid-acquisition-kit.md.

  • "Continue listening" row on /graphene (components/graphene/ContinueListeningRow.tsx): returning-listener surface mounted just above the "How it works" explainer. Scans localStorage for hj_read_position_<seasonId> keys (written by ResumeReadingPill during prior read/listen sessions), joins them against the server-rendered seasons slate, sorts by saved_at desc, and renders the top 3 as poster + episode-number cards linking to /seasons/{id}/read. Self-hides when no matches exist (cold visitors, private tabs) so the homepage never grows an empty shelf. Pure client-side — no auth, no backend round-trip — so anonymous listeners get the same drop-back-in behavior signed-in ones do.

  • SJ Anderson short-stories shelf on /graphene (GrapheneClient.tsx<SjStoryCard> + new linkHref prop on <UniverseSection>): a sibling shelf below the season universes carrying the latest five short stories from /api/short-stories?limit=5. Same horizontal-scroll visual as every other shelf (the 2:3 poster footprint, snap-x rows) so the network reads as one network with two lengths — multi-chapter shows up top, standalone short fiction at the same shelf grain. Cards show DALL-E cover (with typographic ✶ fallback), #position badge, 🎧 chip when audio narration exists, title in serif, word count + minutes, and a small song-line attribution underneath. Chip header links to /sj-anderson for the full collection. Hidden when the public list is empty or when the title-substring filter is active (the filter narrows seasons, not shorts). Failures degrade silently — the shelf just doesn't render.

  • "From write.cafe" winners shelf on /graphene (listRecentCafeWinnersPublic in services/cafe-contests.ts + public GET /api/cafe/contests/recent-winners in routes/cafe-contests.ts + <CafeWinnerCard> in GrapheneClient.tsx): companion shelf to the SJ shorts row — surfaces the latest five weekly-contest winners on the network's main page, driving traffic to writer hubs + completing the funnel. Service over-pools by 4× then dedupes by submission_id (so a story that placed in both ai_assisted and typed_only doesn't appear twice), hydrates submission + contest + writer profile in three parallel round-trips, returns the small subset the homepage card needs. 5-min HTTP cache + stale-while-revalidate. Card shows DALL-E cover (typographic ☕ fallback), place badge (🥇/🥈/🥉/#N), 🎧 audio chip, title in serif, "by writer", word count + minutes, plus a small "week of {date} · theme" attribution below the card. Each card links to /writers/[handle]/stories/[id] — the per-story page that already carries its own OG card with featured-critique blurb. Chip header links to /write-cafe/best-of.

  • "Graphene — Lore" podcast shelf on /graphene (DB table migration 464 graphene_lore_podcasts + endpoints in routes/graphene.ts + GrapheneLoreRow.tsx / helpers in lib/graphene-lore.ts, wired as the first sibling shelf + a top-hero branch in GrapheneClient.tsx): surfaces the author's decade-in-progress epic "Graphene" (the story the network is named after) fragment-first, as inline Spotify episode embeds (NotebookLM-produced lore podcasts). DB-backed so super-admins manage it from the homepage without a deploy: a super-admin-only "+" card appends an episode (paste a Spotify episode URL or bare id → parsed by extractSpotifyEpisodeId; optional kicker + note), an ✕ removes one, and a ☆/★ toggle elevates ONE episode into the top-of-page featured hero — where it renders as a Spotify player in the slot a season audio-snippet would otherwise fill (featuredLore wins the ternary in the hero; season behavior unchanged when nothing is featured). Endpoints: public GET /api/graphene/lore-podcasts; super-admin (authenticateUser + local requireSuperAdmin) POST (append), DELETE /:id, PATCH /:id/featured (clears any prior featured first — a partial unique index WHERE is_featured enforces at-most-one). Row shape { id, spotify_episode_id, kicker, note, is_featured, sort_order }. Renders each as a /embed/episode/<id> iframe player (Spotify's supported inline player — the embed carries art/title/play button, so a note-less card is complete). Violet accent chip; wider snap slots (w-[300px]) than the poster rows, so it's its own row component rather than a <UniverseSection>. Table has RLS on with no public policies (backend-only via service role); migration seeds the first three transmissions. Shelf renders when there's ≥1 episode OR the viewer is a super-admin (so an admin always sees the "+" card); hidden while a title filter is active. No CSP change needed (the app sets none). First surface of the broader "weave Graphene lore into the platform" plan. Also surfaced in the stream: the 2D /dashboard/stream editorial slot alternates a Graphene season poster with a playable lore card (LoreStreamCard.tsx, by slot parity — folded under the existing "Graphene" surface opt-out, no new toggle), and the immersive WebXR river floats a 'lore' StreamNote (fetcher in streamExtras.ts + a CardVisual case in StreamXRScene.tsx) that opens the Spotify episode via the OpenPanel "Open ↗" (WebXR can't host an iframe). In-house "deep dive" generator (migration 466 + services/graphene-lore-generate.ts + super-admin /dashboard/admin/lore-studio): instead of NotebookLM→Spotify, a super-admin pastes source material and we generate the episode ourselves — reusing the podcast-discussion pipeline (generateDiscussionScript claude-sonnet-4-6 → renderDiscussionAudio dual-voice ElevenLabs → ffmpeg stitch → season-assets bucket) — then appends a lore row with audio_url (not spotify_episode_id). Migration 466 drops the spotify_episode_id NOT NULL and adds audio_url/title/script/line_count/source_label + a CHECK that at least one source is present. Endpoints: super-admin POST /api/graphene/lore-podcasts/generate (synchronous, ~1 min; exchanges clamped 3–12) + GET .../voices. The lore cards + hero + 2D stream card branch on isGeneratedLore() — Spotify id → iframe, else a native <audio> player; the immersive WebXR river plays a generated episode's MP3 in-scene via voiceUrl. Manual trigger only (never a cron — spends LLM+TTS). Apply migration 466 to prod.

  • Novel pre-order — "Graphene: Escape from JaJa Island" (SJ Anderson) (DB table migration 465 novel_preorder_signups + services/novel-preorder.ts + routes/novel-preorder.ts mounted at /api/novel-preorder + components/novel/NovelPreorderPanel.tsx + registry lib/novel-preorder.ts): a "notify me at launch" email-capture pre-order for SJ Anderson's first full-length novel, before the Kindle/Audible editions exist. Forward-compatible: the panel captures emails now (Kindle/Audible interest checkboxes → POST /api/novel-preorder/signup, idempotent resubscribe mirroring signupForBestOfNotify), and auto-flips to real "Pre-order on Kindle/Audible" buttons the moment kindleUrl/audibleUrl are filled in the JAJA_ISLAND registry entry (Amazon links get the hivejournal-20 tag via withAmazonTag). The novel is config-in-code (one book, no book table); only signups are in the DB (RLS on, backend-only; GET /signups is super-admin). Surfaces: a dedicated page /graphene/jaja-island (clean URL graphene.fm/jaja-island via a middleware rewrite), a compact panel on the /graphene homepage (below the Lore shelf) and the /sj-anderson page, and a stream promo — the 2D /dashboard/stream Graphene slot is now a 3-way rotation (season poster / lore / NovelPreorderStreamCard) and the immersive WebXR river floats a 'preorder' StreamNote linking to the page. Typographic hex-lattice fallback cover until a real cover art URL is set. Admin view: /dashboard/admin/novel-preorder (super-admin, linked from the admin index Operations group) lists the waitlist with Total / Kindle / Audible / per-day stat cards, Kindle+Audible interest pills, and a Copy-CSV button. Apply migration 465 to prod (Supabase MCP ≠ prod).

  • Graphene VR Story Player (Phase 1) (/vr hub → canonical room /seasons/[id]/vr, sibling to /read + /timeline; the room UI is the reusable StoryVrRoom.tsx, and the old /vr/[seasonId] now server-redirects to it). Discoverable from: a 🥽 VR chip in the /read chrome cluster (novel-mode), a 🥽 Step inside a story in VR → tertiary link in the /graphene hero, and the /vr hub card grid. It's a public WebXR "listening room" — you stand inside a chapter's art (an inverted image sphere) with the narration playing, in-scene Prev/Play/Next transport, on a Meta Quest / Apple Vision Pro (WebXR immersive-vr, on by default in visionOS 2) or desktop (OrbitControls). Scene StoryVrScene.tsx clones the shipped StreamXRScene pattern (fiber v9 + drei + xr v6 + three; createXRStore/<XR>/<XROrigin> + Enter-VR button owned by the scene; loaded dynamic(ssr:false) so no @react-three/* reaches page scope — the Next-15/React-19 prerender landmine). The only new rendering piece is the image-textured 360 sphere (meshBasicMaterial map on a BackSide sphere, dimmed by a tint). Audio via applyChapterMediaSession (OS/headset now-playing card) on a persistent <audio> element that auto-advances chapters. Renderer-agnostic data layer grapheneChapters.ts (VrStory/VrChapter) pulls from public GET /api/story-seasons/public (Graphene stories = is_published_to_graphene, novel-mode) + GET /:id/chapters (chapter_cover_image_url + audio_url); no new backend/migration. Phase 1.5a — wider sphere art: public GET /:id/vr-backdrops (services/vr-backdrops.ts, gated to published Graphene stories) returns the first 16:9 Scene Studio keyframe (scene_shots.keyframe_url) per chapter; the room uses it for the sphere (wraps better than the 1:1 cover) and keeps the square cover as the crisp focal panel. No generation/migration — reuses existing scene art; chapters without it fall back to the cover. Kept off the hot /chapters response so only the VR data layer pays the scene lookup. Enrichment v1 — "inhabit the chapter" (reuse-only, no generation): public GET /:id/vr-entities (services/vr-entities.ts, same Graphene-novel gate) returns a few VrEntitys per chapter — character portraits (public universe-canon portrait path, matched to names in the chapter prose and timed to first-mention paragraph via season_audio_segments durations — or, for whole-chapter-audio seasons with per-paragraph durations all 0, paced proportionally across chapter_audio_duration_seconds so fade-ins still land on their beat) + "moment" keyframes (later scene_shots, the sphere uses the first), capped 4/chapter. The scene floats them on a billboarded arc (FloatingEntity/EntityField in StoryVrScene) and fades each in when the <audio> timeupdate elapsed passes its timeOffsetSeconds. VrChapter gains entities: VrEntity[] (best-effort 4th parallel fetch in grapheneChapters). Enrichment v2 — the generate half (items/props): super-admin POST /:id/vr-entities/generate (services/vr-entities-gen.ts) LLM-extracts up to 3 standalone items/props per chapter from the prose + synths a 1:1 cutout each (keyframeBuffer Flux), persisting to vr_chapter_entities (migration 468). Missing-only, budget-capped (assertGenBudget('vr_entities.', VR_ENTITIES_DAILY_CAP_USD def $10), re-checked per paid call), cost auto-metered — on-demand only, never a cron/read, so reads never spend. getVrEntities merges image_url-ready items (kind item, timed to first mention) into a round-robin with characters + moments (variety, capped 5); items render as smaller cutouts at "hand" height. Tuning aid: a ?tune=1 URL param + an in-scene "Timing" toggle force-show every entity at once (ignore narration timing) so placement can be adjusted in-headset. Plan + phased roadmap (real equirect art, Drift-in-space, branching+crowd-choices, cross-surface EmberKiln/Odessa/Rehearsal reuse) in docs/product/GRAPHENE_VR_STORY_PLAYER.md. Built blind — visual positions/scales are first-guesses to tune on-device.

  • Graphene VR — Walk Mode (Phase 1 shipped) (/seasons/[id]/vr/walkStoryWalkRoomStoryWalkScene). A second VR mode, sibling to the standing listening room: instead of a photosphere you walk through a 3D forest. Locomotion: teleport (TeleportTarget) + smooth thumbstick walk w/ snap-turn (useXRControllerLocomotion) in VR; pointer-lock + WASD on desktop (gated non-session via useXR). Environment: a procedural low-poly forest (instanced trunks + foliage) as a placeholder — swapping a real forest GLB is a one-component change (<Trees/>useGLTF, the DomeScene pattern). Reuses the renderer-agnostic fetchVrStory/VrStory contract + the createXRStore/<XR>/XROrigin stack. Cross-linked from the listening room ("walk it →"). Phase 2 shipped — memory marbles (pure discovery): the season's active Drift treasures become glowing marbles buried at deterministic spots (hash01(treasure.id)), each with its hint floating above; walk within ~2.3m and the memory surfaces (proximity-reveal, works in VR + desktop) showing the treasure's description. Data via anonymous-safe fetchDriftTreasures(seasonId) (lib/drift.ts) → the privacy-stripped GET /api/drift/seasons/:id/treasures (client never gets text_anchor) — no backend/migration. Phase 3 shipped — wind-blown journal pages: ~20 parchment WindPage planes drift through the forest on the wind (atmosphere, textless by design; the "journals from the garbage truck" lore image). Phase 2b — coin-crediting shipped: signed-in walkers earn a treasure's coins on a find via POST /api/drift/treasures/:id/vr-claim (authenticateUser) → claimTreasureSpatial (claim-by-id, no prose; once per user; mutateBalance treasure_claim); client claimVrTreasure(id) fired from the marble's onFound, anonymous callers 401-swallowed (reveal-only stays the anon default). Phase 2c — authored coordinates shipped: migration 469 adds nullable vr_position jsonb ({x,z}) to drift_treasures; the Marble honors it (else scatter). Authoring = an in-scene "author drop" mode (/seasons/[id]/vr/walk?author=1 on desktop → walk to a spot → "📍 Drop next memory here" → PATCH /api/drift/treasures/:id/vr-position, owner-gated via requireTreasureOwner). Still planned: a garbage-truck source GLB (see docs/product/GRAPHENE_VR_WALK_MODE.md). Built blind — tune on-device.

  • The Studio — novel-writing cockpit (Phase 0) (/dashboard/admin/studio index → /dashboard/admin/studio/[seasonId]): the first build step of NOVEL_STUDIO_PLAN.md — a super-admin, season-scoped unification of the writing surfaces into one five-mode cockpit (Draft / Listen / Adapt / Produce / Publish). Draft reuses the real ProseEditorModal + chapter engine (/api/story-seasons/:id/editor-chapters); the other modes are launchpads into the existing per-season production/distribution admin surfaces (audiobook, screenplay, scene studio, covers, print, distribution, gating, pre-orders). JQ rides along via the jq:open-canon event. Bespoke dark "cosmos" frame (not DashboardLayout). Client component + useParams (no useSearchParams?demo= read from window.location to avoid the Suspense build gate). Phase 1 (shipped): Draft renders the editor inlineProseEditorModal gained an embedded prop (swaps only its two outer wrapper divs: no fixed overlay, fills its container; all 500 lines of editor logic — autosave, inline AI, bible panel, Ask JQ — unchanged, so the modal still works everywhere else) and the cockpit mounts it full-height in the Draft column for the selected chapter (bible panel + Ask JQ live inside it). Clicking a left-rail chapter selects it + switches to Draft; the editor's own ‹ N of M › nav updates the selection. Canon dock "Check continuity" runs a deterministic bible-vs-prose audit (GET /api/story-seasons/:id/canon-health — no LLM, via the shared computeCanonHealth in chat/canon-mode.ts, the same report JQ's canon_health tool returns: overdue/untargeted threads, arc-less active characters, unpaid motifs, unmentioned characters, first-appearance drift, orphan relationship refs) and shows a ✓/⚠N badge + finding list + "Fix with JQ". The dock auto-runs the check on cockpit open (2026-07-10) so the badge + top findings surface without a click. Chapter-list planning badges (shipped 2026-07-12): each manuscript-list row now shows its dramatic role chip, a color-graded stakes intensity chip (⚡N, sky→amber→rose so a flat run reads as a flat band — the "where's my midpoint?" tell), and a ◈ spine marker (central-turn on hover) — a read-only projection of already-stored dramatic_role/chapter_spine/chapter_stakes (no LLM), via deriveChapterBadges (see the editor-chapters endpoint above). Remaining Phase-1 polish: theming the amber editor into the violet cockpit + a native JQ rail.

  • The Cockpit — VR-centered Studio (experimental) (/dashboard/cockpitcomponents/cockpit/CockpitScene.tsx): an experimental WebXR reimagining of The Studio. You sit in a fixed cockpit under a starry sky, type on a Bluetooth keyboard, and your thoughts appear live on a big screen in the sky ahead of you ("sky mode"); around you, curved like a console, float launch panels for every HiveJournal surface (Stream, Immersive Stream, New Entry, Notebooks, Goals, DreamPro, Odessa, Rehearsal Room, Write Café, The Studio, Dome, Demos). Runs in the Quest 3 browser ("Enter VR") and previews on desktop (drag to look, type on your keyboard, click a panel to launch). Save: ⌘/Ctrl+Enter posts the typed buffer to POST /api/journal (only content required; first line → title); Esc clears; the draft mirrors to localStorage (cockpit:draft). Architecture mirrors the shipped WebXR pattern exactly: the scene is a dynamic(ssr:false) import owning the createXRStore/<XR>/<XROrigin> stack + Enter-VR button (nothing from @react-three/* at page module scope — the Next-15/React-19 prerender landmine), and the page owns the typed buffer + the save (window keydown capture) so the scene is a pure render of text/caretOn/saveState. Comfort rule inherited from The Stream: the user never moves. Experimental bet: a paired Bluetooth keyboard's keystrokes reach the page as normal keydown events inside an immersive-vr session (the desktop path works fully today regardless). Discoverable via a 🛰 The Cockpit pill in The Studio header + a pinned Apps strip in the logo feature menu / ⌘K palette. No backend/migration — reuses the journal API. Built blind — in-headset positions/scales are first-guesses to tune on-device. Enhancement pass (2026-07-12): the sky is now your journal — recent entries (GET /api/journal?limit=14) hang as a constellation of openable stars (deterministic spherical placement by id-hash; a star with a photo — background or first attachment — shows a small framed thumbnail via the shared VrImage, filled progressively from a bounded attachments pass; click → the entry), and saving a thought launches a new star up from the screen into the sky (session-local, links to the created entry). Plus 4 sky themes (Deep space / Dawn / Nebula / Aurora — a shader-uniform tint on the dome + fog + light, localStorage cockpit:theme) and a console readout ("✦ N released tonight · M words"). Enhancement pass 2 (2026-07-12): a notebook-focused constellation (a picker populates the sky from one notebook via GET /api/journal?notebook_id=, or all), a rotating writing spark shown on the empty sky screen (a curated nudge that rotates every 18s, replaced the moment you type), and an opt-in ambient hum — a self-contained WebAudio drone (detuned sine oscillators through a slow-drifting lowpass, no asset; starts on the toggle per autoplay policy, fades in/out, tears down on unmount). All still page-owns-buffer / scene-is-pure-render; desktop-verified via full next build.

  • VR hub — "HiveJournal in VR" (/dashboard/vr): a plain 2D front door that gathers every immersive room (The Cockpit, Immersive Stream, Your Dome, per-entry "Step inside", Graphene Story Player, Wall Breaker) so the WebXR layer reads as one system, not scattered pages. Detects headset support (navigator.xr.isSessionSupported('immersive-vr')) for a "Headset ready / Desktop preview" badge; every card links out to a room that owns its own Enter-VR button. No WebXR at this page's scope (it just links), so no SSR risk. Discoverable via the Apps strip in the feature menu (🥽 HiveJournal in VR) + the ⌘K palette.

  • Entry VR — "step inside this entry" (/dashboard/entries/[id]/vrcomponents/entry-vr/EntryVrScene.tsx): a WebXR reading room for a single journal entry. You sit inside the entry's own background art (an inverted, dimmed image sphere — copies StoryVrScene's PhotoSphere manual-TextureLoader/crossOrigin/tint pattern; a themed gradient-sky fallback when there's no art) with the note's attached photos framed above the reading panel (a pager gallery when there's more than one — GET /api/journal/entries/:id/attachments, the same images the Stream floats; tap a photo to pull it up large in a dim click-out focus overlay; the first also becomes the backdrop sphere when the note has no background_image) and the note on a reading panel in front, paged like a book (paginate splits the content ~520 chars on word boundaries, in-scene ◄/► pager), and it reads itself aloud when a voice render exists (▶ button → the cached audio_url from GET /api/lovio/entries/:id/voice-status, applyMediaSession). Runs in the Quest 3 browser ("Enter VR") + previews on desktop (OrbitControls, click the arrows/play). Data: GET /api/journal/:id (title/content/background_image/font/notebook color+font); background resolved to a public bucket URL (or the notebook cover), troika font = the entry's PremiumUltra .ttf. Same WebXR discipline as the Cockpit/story rooms: scene is a dynamic(ssr:false) import owning createXRStore/<XR>/<XROrigin> + Enter-VR button (no @react-three/* at page module scope); the page fetches + paginates and stays XR-free; comfort rule (user never moves). Discoverable via a 🥽 Step inside in VR link in the entry's back-button row. No backend/migration — reuses the journal + voice-status APIs. Built blind — in-headset panel/sphere scale is a first guess to tune on-device.

  • Notebook VR — your notebooks as a library in the round (/dashboard/notebooks/vrcomponents/notebook-vr/NotebookVrScene.tsx): a WebXR room where you sit at the centre of a ring of your notebooks — each a standing book with its cover (curated /images/covers/* asset, resolved via the shared VrImage), colour spine, name, and entry count; the ring wraps to stacked rows above/below eye level when there are many (PER_RING=10), all facing the seated origin. Open a notebook (click / gaze-select) and it swaps to an opened view: the cover floats large ahead while its entries fan out overhead as a constellation of stars — the same EntryStar glow/photo/label pattern as the Cockpit (deterministic id-hash placement, GET /api/journal?limit=24&notebook_id=, photos filled from a bounded attachments pass), each star opening that entry; a "← all notebooks" button returns to the ring. The collection-scoped sibling of Entry VR (which is one entry). Same WebXR discipline: scene is a dynamic(ssr:false) import owning createXRStore/<XR>/<XROrigin> + Enter-VR button (no @react-three/* at page module scope); the page owns the notebook fetch + the opened-notebook entry fetch (with an o.id===id race guard) and stays XR-free; comfort rule (user never moves — turn to look). Amber (#f59e0b) chrome. Discoverable via a 🥽 View in VR pill on /dashboard/notebooks, the VR hub, the Apps strip, and the ⌘K palette. No backend/migration — reuses the notebooks + journal APIs. Built blind — ring radius / row spacing / book size / entry-star radius are first guesses to tune on-device.

  • Inspiration Room — a hands-free VR montage (/dashboard/inspiration/vrcomponents/inspiration-vr/InspirationVrScene.tsx; doc INSPIRATION_ROOM.md): a new VR-hub room — an auto-advancing montage of uplifting quotes/affirmations + the viewer's own coach encouragement / "on this day" memories / journal photos + curated clips, on a single eye-level panel that eases in each ~7s under a starfield. Blended server-side by services/inspiration.ts buildMontage (curated house quotes/affirmations as the always-present backbone + personal slides weaved in; read-only, no LLM); GET /api/inspiration/montage{ slides }, mounted at /api/inspiration (routes/inspiration.ts). Clips live in an editable table (migration 479 inspiration_clips, RLS-closed): a hosted video_url plays IN-VR on a CinemaScreen (THREE.VideoTexture; the tap that starts it is the required gesture + it pauses the montage until it ends), while a youtube_id shows as a poster + "open on YouTube" out-link in the room and gets its real iframe player on the 2D page (GET /api/inspiration/clips → the gallery under the canvas) — you can't put a DOM iframe inside a WebXR canvas, and YouTube's ToS forbids texturing their stream, so hosted files are the only in-VR video path. Same WebXR discipline as the other rooms: scene is a dynamic(ssr:false) import owning createXRStore/<XR> + Enter-VR button (no @react-three/* at page module scope); page fetches montage+clips and stays XR-free; comfort rule (fixed camera). Registered in the VR hub ROOMS. Spoken montage (built): an opt-in 🔈 Narrate toggle reads each line aloud in the HOUSE voice via POST /api/inspiration/narrate (reuses studio-narrate.ts narrateLine — render-once/content-hash cached in season-assets; 204 when unconfigured → stays visual-only); when on, the montage paces to the narration (advance on audio ended, 25s safety), prefetches the next line, and binds applyMediaSession for the now-playing card/glasses. Opt-in-to-play (the toggle is the gesture that unlocks audio; never autoplay). Voice picker: house voice (default) or the viewer's own cloned voice (🎙 Your voice / 🏠 House) — shown only when GET /api/inspiration/voice-readiness reports a JQ-consented clone; own voice routes through sayInUserVoice (consent-v2 gated, jq_voice_clips cache) and falls back to house when the clone isn't set up so it always speaks. Clip curation UI (super-admin): /dashboard/admin/inspiration-clips (admin tools grid + ⌘K) — paste a YouTube URL (title/poster auto-filled via utils/youtube.ts extractYouTubeVideoId+fetchYouTubeOEmbed oEmbed) or a hosted MP4/HLS URL, show/hide (is_active) or remove; GET|POST /api/inspiration/admin/clips + PATCH|DELETE /api/inspiration/admin/clips/:id (super-admin). Apply migration 479 to prod. Seeded YouTube IDs are placeholders — curate via the admin page. Opt-in ambient pad: a 🎵 Ambience toggle plays a warm asset-free Web-Audio synth pad under the montage (lib/useAmbientPad.ts, opt-in-to-play, mirrors useAmbientRiver). House narrator voice is DB-backed + UI-settable: services/house-voice.ts getHouseVoiceId() resolves site_settings.house_voice_idSTUDIO_HOUSE_VOICE_ID env → stock Matilda (60s cache); a super-admin sets it on the clip-curation page (curated listHostVoices dropdown or a pasted voice id; GET|PUT /api/inspiration/admin/house-voice), effective immediately — the voice id is in the narration cache key so a change renders fresh clips. studio-narrate.narrateLine now resolves via it (so Demo Studio's narrator is UI-settable too, no more STUDIO_HOUSE_VOICE_ID-only). Next: glasses audio-first montage. Built blind — panel/screen scale to tune on-device.

  • Demo Studio — hands-free, house-voice product demos (migration 467 studio_demos + services/studio-narrate.ts + services/studio-demos.ts + routes/studio-demos.ts at /api/studio-demos + components/studio-demo/GuidedDemo.tsx + library /dashboard/admin/demo-studio): a library of self-driving demos. A demo is ordered steps { caption, say, action?, dwell_ms? }; the GuidedDemo runner plays it hands-free — running each action on the host surface (e.g. mode:listen), showing the caption, and playing the step's house-voice narration — so a demo can be screen-recorded with no presenter. Narration is rendered via ttsSegment (house voice = STUDIO_HOUSE_VOICE_ID, default Matilda) and content-hash cached in the season-assets bucket, so a demo re-generates for free when its script or the house voice changes — only changed lines re-render (that's the "keep the library latest-version" mechanism). Endpoints (all super-admin): GET / (library), GET /:slug, GET /:slug/prepared (steps + narration URLs — what the runner plays), POST / (upsert), DELETE /:slug, POST /narrate (ad-hoc lines). The host surface mounts <GuidedDemo slug onAction autoStart /> and maps ?demo=<slug>. Seeds the studio-cockpit-tour demo. Apply migration 467 to prod. Auto-demo Phase 0 — LLM demo authoring (shipped 2026-07-12, design): the admin page gained an ✨ Auto-generate control that picks a feature from feature-index.ts and has the house-voice narrator draft a full steps[] script — services/demo-author.ts (buildAuthorPrompt + parseAuthoredDemo are pure + golden-tested in demo-author.test.ts; authorDemoForFeature runs WRITER_QUALITY_MODEL_KEY then persists) via POST /api/studio-demos/author { feature: { name, description, routes? } }. Generated demos land as status='generated', is_published=false — a review queue, never auto-published (approve-before-publish is the locked design); the admin board shows a needs-review badge with Approve/RejectPATCH /:slug/status (approve sets is_published=true). The demo action vocabulary is the generic persona-browser verb set (nav/click/scroll/type/highlight/wait, services/demo-actions.ts normalizeAction, golden-tested) alongside the legacy mode:* host actions — so a demo can drive any feature's page, not just the cockpit. Migration 472 adds status/source/feature_name/generated_at to studio_demosapply 472 to prod before using the author endpoint (the upsert writes those columns). Auto-demo Phase 1 — public /demos page (shipped 2026-07-12): a public, no-auth advertising surface /demos (server component for SEO + OG via api/og/demo/[slug]) lists approved+published demos, each playable hands-free as a house-voice narrated caption walkthrough (components/demos/PublicDemoPlayer.tsx). Served by public GET /api/studio-demos/public (catalog) + GET /api/studio-demos/public/:slug/prepared (one approved demo + narration) — both filter status='approved' AND is_published=true and strip internal fields, registered before the super-admin GET /:slug so /public isn't captured as a slug. Auto-demo Phase 2 — rendered MP4 (v1 shipped 2026-07-12): services/demo-video.ts renders a demo to a narrated MP4 — one renderHtmlToPng caption frame per step held for that step's house-voice narration duration, concat + muxed with the bundled ffmpeg (pure arg builders in services/demo-video-args.ts, golden-tested; video + audio are both Σ(step durations) so -shortest mux never drifts). Columns video_url/poster_url/video_status (migration 473 — apply to prod). Admin 🎬 Render video button → POST /api/studio-demos/:slug/render-video (super-admin, fire-and-forget → video_status rendering→ready/failed); public /demos shows the MP4 (poster_url + <video>) with the live narrated player as a "Try it live" fallback; the public list returns video_url/poster_url only when video_status='ready'. Landmine: the persona-browser pool launches headless-shell Chromium, which cannot recordVideo — true screen-recording needs full Chromium + --headless=new + xvfb; v1 uses caption cards. Phase 2b — real-UI screenshot frames (shipped 2026-07-12): services/demo-capture.ts drives the shared headless-shell Chromium (getSharedBrowser) through a demo's action sequence (nav/click/scroll/highlight/type), optionally signed in as DEMO_CAPTURE_USER_ID via an admin magic-link (auth.admin.generateLink, the auth-sso pattern), and page.screenshot()s the real page per step — the frame is that screenshot with a caption bar (captionedScreenshotHtml). Config-gated + fallback-safe: only runs when DEMO_CAPTURE_BASE_URL (deployed frontend origin) is set; any per-step failure falls back to a caption card, so it's dormant until configured and can never fail a render. Screenshots work on headless-shell (no xvfb); built on prod-proven primitives but validate on prod. Phase 2c — Bluesky posting (shipped 2026-07-12): services/demo-social.ts postDemoToBluesky posts an approved, video-ready demo's MP4 natively to Bluesky via cafe-bluesky.ts bskyPost({ embedVideoUrl }) (the channel with automated video upload). Human-triggered (admin 🦋 Post to Bluesky button → POST /api/studio-demos/:slug/post-bluesky) per approve-before-publish — refuses unless approved+published+video_status='ready', won't double-post (stores bluesky_uri/bluesky_posted_at, migration 474). Caption via buildDemoCaption (golden-tested, 300-char clamp). Phase 3 — Demo Manager auto-PICK (v1 shipped 2026-07-12): is_demo_manager flag on ai_personas (migration 475, mirrors is_platform_writer); services/demo-manager-cron.ts runDemoManagerTick picks the next feature from the curated services/demoable-features.ts list with no tour yet and LLM-authors one → status='generated' (pending review — never approves/renders/posts). Bounded (one/tick; stops at review-queue cap 8 or all-covered); creator = the is_demo_manager persona → DEMO_MANAGER_USER_ID → any super-admin. index.ts loop (6h) + recordHeartbeat('demo_manager_tick') + CRON_REGISTRY.demo_manager (OFF by default, kill switch at /dashboard/admin/crons). Apply migrations 474 + 475 to prod. Phase 3 refinement — full-registry auto-PICK (shipped 2026-07-12): the pick source is no longer just the curated list — fetchDemoableFeatures now merges the curated 8 with the drivable, user-facing slice of the frontend feature-index.ts registry, read at runtime from the public GET /api/features/demoable feed (app/api/features/demoable/route.ts) since the registry lives in the frontend workspace. Pure, unit-tested filter/merge in services/demoable-features.ts: toDemoableFeatures keeps features with a concrete (param-free) route and drops Admin & Operations + Public & Marketing Pages categories; mergeDemoable lets curated entries win on a normalized name clash (so DreamPro/Dream Pro collapse). So a new routed feature added to the registry gets a demo automatically next tick — no edit to the backend list. Fault-tolerant: no DEMO_CAPTURE_BASE_URL, a fetch/timeout error, or bad JSON all fall back to the curated list alone, so the cron never breaks on a registry hiccup. Phase 3 refinement — stale-demo regeneration (shipped 2026-07-12, migration 476): each demo stores a feature_hash (sha256 of the source feature's name+description+routes, stamped at author time; featureHash in demoable-features.ts, route-order-insensitive). Once every feature is covered, runDemoManagerTick re-authors the first demo whose live feature has drifted (stored hash ≠ current) via pickStaleFeaturestatus='generated' (pending review, so approve-before-publish still gates the refreshed demo). New-coverage is prioritized over stale-regen; still one LLM author/tick. Pre-476 demos (no hash) are not treated as stale — the cron adopts their current state as a baseline (featuresNeedingBaselinesetDemoFeatureHash, cheap, no LLM/unpublish) so drift is measured from deploy forward, not a mass re-review. rejected/archived demos are never resurrected by drift. Pure featureHash/featuresNeedingBaseline/pickStaleFeature unit-tested. Apply migration 476 to prod (studio-demos.ts COLS SELECTs feature_hash). Phase 3 refinement — earned autonomous posting (shipped 2026-07-12): past the human "🦋 Post to Bluesky" button, a demo_autopost cron (services/demo-autopost-cron.ts runDemoAutopostTick, 6h loop in index.ts, heartbeat demo_autopost_tick) posts one approved+published+video-ready demo not yet posted (studio-demos.ts nextAutopostableDemo, oldest-first) via the same postDemoToBluesky — so it can never publish on its own, only skip the click on demos a human already cleared. A SEPARATE trust tier from demo_manager (authoring): two independent toggles, so you can auto-author while keeping posting manual (or vice-versa). Bounded (one post/tick, won't double-post), no LLM cost (native video upload), OFF by default (CRON_REGISTRY.demo_autopost, env DEMO_AUTOPOST_ENABLED, kill switch at /dashboard/admin/crons), no-op without BSKY creds. Control-flow guardrails unit-tested (demo-autopost.test.ts). On-page admin console (shipped 2026-07-20): the public /demos page mounts components/demos/DemoAdminConsole.tsx — a client component that renders null for everyone except super-admins (no SSR/SEO impact), and for them surfaces the demos the page hides (generated / draft / rejected / approved-but-unpublished) with inline Publish/Hide toggle, Request changes & redo, Reject, and a Generate a demo bar — so review + publish happens without leaving the marketing surface. New backend seams: PATCH /api/studio-demos/:slug/publish { is_published } (studio-demos.ts setDemoPublished — flips ONLY is_published, leaving review status intact, so an approved demo can be hidden without demoting it); and POST /author now accepts an optional guidance string (reviewer revision notes) that buildAuthorPrompt(feature, guidance?) folds in as a required directive on a redo (golden-tested). Publish on a not-yet-approved demo still routes through /status (approve → published); mutations call router.refresh() so a publish shows in the server-rendered public grid immediately. Deep edits (step JSON, render video, Bluesky, delete) stay in /dashboard/admin/demo-studio, linked from the console. No new migration (status/is_published already exist). Narrate demos in the OWNER'S voice (shipped 2026-07-20): a Demo Studio toggle switches the demo narrator from the house voice to the owner's own consented Living Voice clone — services/studio-demo-voice.ts (DB-backed site_settings.studio_demo_voice_id, mirrors house-voice.ts; getDemoNarratorVoiceId = clone-override-else-house, setDemoNarratorToClone resolves getActiveVoice with the same JQ_VOICE_MIN_CONSENT_VERSION gate About That uses). narrateLine(text, {voiceId?}) gained an override; the demo paths (narrateSteps, demo-video.ts, /narrate) pass the resolved demo voice while the Inspiration Room stays on the house voice (scoped). Because narration caches per (voiceId::text), switching just re-renders each line under a new hash on the next Regenerate/play — old clips stay, switching back is free. Endpoints GET|POST /api/studio-demos/narrator-voice (super-admin; {mode:'clone'|'house'}). No migration (site_settings exists). Login-wall guard + Demo Content System (shipped 2026-07-22, migration 559, PR #1473 + demo-content-roster): the Phase 2b capture was screenshotting the /auth/signin wall as a "feature" frame when the magic-link session didn't establish (a "Stream View" frame showing a sign-in form). demo-capture.ts now detects the auth wall (URL /auth/signin|/login + a DOM password/sign-in signature) and returns a null frame → caption-card fallback, and verifies the magic-link session actually took (re-loads /dashboard, retries once with a fresh link, recordServiceError on failure) — so a login page can never ship as a feature frame. Paired with a demo roster: ai_personas.demo_role (migration 559) tags a small set of existing personas — @ishaan-r (user, primary capture), @jordan-k-3 / @leona-g (interaction, multi-persona demos), @arjun-k (admin, also profiles.role='super_admin') — designated via scripts/designate-demo-roster.ts (ROSTER array = source of truth, --stats). A bounded demo_roster_activity cron (runDemoRosterActivity, 6h loop in index.ts, ≤1 journal entry per member when their latest is >20h stale, hard per-tick cap 6, heartbeat demo_roster_activity_tick, OFF by defaultisCronEnabled toggle AND env DEMO_ROSTER_ACTIVITY_ENABLED) enriches ONLY the roster (persona_sim drives every persona + is a top spend burner; this is the scoped, predictable-spend alternative). demo-video.ts signs the capture in as DEMO_CAPTURE_USER_ID or the roster user persona via captureUserForRole('user') — self-wiring once designated; captureUserForRole('admin'|'interaction') is exported for per-surface + multi-persona phases. Doc: DEMO_CONTENT_SYSTEM.md. Apply migration 559 + run the designation script to prod.

  • "Your journals" panel on /write-cafe (GET /api/write-cafe/my-journals in routes/write-cafe.ts + components/write-cafe/MyJournalsPanel.tsx on /write-cafe/page.tsx): every signed-in cafe member now sees their HiveJournal notebooks + a synthetic "Bento shorts" tile on the cafe homepage so they can jump straight into writing. Endpoint auto-bootstraps a "Random Thoughts" notebook (warm-amber color, cafe-themed description) when the writer has none — empty state never blocks day-one writing. The Shorts tile is virtual (computed from cafe_contest_submissions count + most-recent submitted_at) so it always reflects current state and links to /write-cafe/my-entries. Each notebook card shows entry count + relative-time last-entry. Hidden when the API call fails or the writer has nothing to render (defensive — never blocks the rest of the page).

  • "Notify me when it ships" CTA + richer empty state on /write-cafe/best-of (migration 204 + signupForBestOfNotify in services/cafe-best-of.ts + POST /api/cafe/best-of/notify-signup in routes/cafe-best-of.ts + <NotifyCTA> + <WinnerGlimpse> in BestOfListClient.tsx): single-opt-in email capture for "tell me when Vol. N ships" + a richer empty-state landing experience while Vol. 1 is still being curated. Migration adds cafe_best_of_notify_signups (separate table from graphene_subscribers so the unique-on-email constraint doesn't collide; semantically distinct lists). Service enforces a basic email shape, dedupes by lowercased email, resurrects unsubscribed rows on re-signup. Frontend: empty state replaced with a "Vol. 1 — coming soon" panel + the email CTA hoisted above the fold + a 6-card "recently winning · a glimpse" grid pulling from the existing /api/cafe/contests/recent-winners endpoint. Populated state also gets a CTA at the foot for retention across future volumes. Three confirmation states (created / already_subscribed / resubscribed) so the message after submit is honest. notified_volumes text[] on the table is a small dedupe ledger — when admin runs the one-shot send for a new volume, slugs get appended so a single signup can span multiple volumes without double-emailing.

  • Season pages now include a <GrapheneMark /> badge (apps/frontend/src/components/graphene/GrapheneMark.tsx) — hex-lattice icon + uppercase wordmark, links to /graphene.

  • Subscription flow (double opt-in, Resend confirmation email):

    • Service: apps/backend/src/services/graphene-subscribers.tssubscribe(), confirm(), unsubscribe(). Token is crypto.randomBytes(32).toString('base64url') and doubles as the unsubscribe key.
    • Routes (apps/backend/src/routes/graphene.ts): POST /api/graphene/subscribe (public), POST /api/graphene/confirm (public), POST /api/graphene/unsubscribe (public), GET /api/graphene/subscribers (super-admin).
    • Frontend pages: /graphene/confirm, /graphene/unsubscribe.
  • Light viewer-taste intro on /graphene (migration 132_graphene_viewer_taste.sql): adds profiles.graphene_taste jsonb storing { genres, tones, pace, completed_at, skipped_at, version }. Component GrapheneTasteIntro.tsx renders an inline card (NOT a modal) above the Shows grid asking "what kind of shows do you enjoy?" — with chip groups for Genres (pulled from the live slate so they reflect what's actually produced), Tones (curated: dread/character_study/twisty/cozy/literary/pulpy), and Pace (slow / fast). Save + Skip both stamp a timestamp; either being set marks the intro as done so it never renders again. Routes: GET /api/graphene/taste + PUT /api/graphene/taste in routes/graphene.ts (auth required, server-side sanitizer caps array sizes + lowercases strings + stamps timestamps server-side so they can't be backdated). Slate ranker in GrapheneClient.tsx filteredSeasons memo: when taste.genres is non-empty, performs a stable sort to bubble matching shows to the top — additive only, never filters. URL filter (?filter=genre&q=...) takes precedence (already filters; no need to re-rank). PostHog: graphene_taste_saved (with genre_count, tone_count, pace) and graphene_taste_skipped. Anonymous viewers and returning viewers with saved/skipped state see no card.

  • Engagement-triggered prompt: EngagementTracker writes graphene:season:{id} (first_seen, last_seen, visit_count) to localStorage on every visit to /seasons/[id] or /seasons/[id]/read. After 3 days of return-engagement (configurable via GRAPHENE.engagementDaysBeforePrompt), surfaces the SubscribeModal with the source season recorded for analytics. Dismissed and subscribed states are persisted per-device in localStorage so it never re-prompts.

  • Migration: 095_graphene_subscribers.sqlgraphene_subscribers table with email (unique), source_season_id, source, confirmation_token (unique), confirmed_at, unsubscribed_at. RLS: super-admin SELECT only; writes go through the backend service-role client.

  • Graphene+ paid tier ($5/mo listener subscription, Phase 1) — Stripe-backed sub that keeps story-content audio open past the 7-day free window. Plan in docs/product/PHASE1_LISTENER_SUBSCRIPTION.md; strategy context in docs/product/GRAPHENE_MONETIZATION.md. Free model: meta content (universe podcasts, trailers, behind-the-scenes) is always free; story content (per-chapter audio) is free for 7 days from render, then subscriber-only.

    • One subscription, both products — Graphene+ also unlocks the full HiveJournal premium tier. The isPremiumUser check in routes/drops.ts intentionally doesn't filter by plan_key — any active row in subscriptions grants premium, so a Graphene+ subscriber gets every paid HiveJournal feature (premium handwriting fonts, doubled monthly drops, etc.) at the same $5/mo. Surfaced in messaging on /graphene-plus (perks + FAQ + paid bullets), GraphenePlusModal (perks list + pitch line), and /dashboard/subscription (active-state pill says "HiveJournal premium included"). If a future tier should NOT grant HiveJournal premium, filter it out in isPremiumUser AND update the marketing copy in the same change.
    • Schema: migration 129_subscriptions_plan_key.sql extends subscriptions with plan_key (rows discriminated by tier — Phase 1 only ships 'graphene_plus'), stripe_price_id, current_period_end, cancel_at_period_end. Migration 130_chapter_free_until.sql adds story_episodes.chapter_free_until timestamptz; existing chapters grandfathered to '2099-01-01' (always free).
    • Render-time stamp: season-novel-chapter-audio.ts renderChapterAudio writes chapter_free_until = renderedAt + 7 days on every successful render. Re-renders reset the window — admin "fix audio" workflows are a future TODO; for now, every render is treated as "new release."
    • Subscription helpers: apps/backend/src/services/graphene-subscription.tsisGrapheneSubscriber(userId) (treats cancel_at_period_end=true as still active until current_period_end), getGrapheneSubscription(userId), isChapterWithinFreeWindow(chapter_free_until). GRAPHENE_PLUS_PLAN_KEY = 'graphene_plus'.
    • Stripe wiring in apps/backend/src/routes/payment.ts: the existing webhook now upserts on stripe_subscription_id (replay-tolerant, handles re-subscribe after cancel) and captures plan_key (mapped from STRIPE_PRICE_GRAPHENE_PLUS and STRIPE_PRICE_GRAPHENE_PLUS_ANNUAL env vars — both intervals share plan_key='graphene_plus'), stripe_price_id, current_period_end, cancel_at_period_end. New endpoints: POST /api/payment/customer-portal (Stripe billing portal session for cancel/manage card), GET /api/payment/graphene-plus/status (returns { subscriber, subscription }), GET /api/payment/graphene-plus/config (returns { monthly_available, annual_available } so the frontend toggle self-hides when annual isn't configured), POST /api/payment/graphene-plus/checkout (accepts optional { interval: 'monthly' | 'annual' }, defaults to monthly; metadata.interval propagates into Stripe). Required env: STRIPE_PRICE_GRAPHENE_PLUS. Optional env: STRIPE_PRICE_GRAPHENE_PLUS_ANNUAL for annual ($48/yr — prepays 12 months for the price of 10, $12 savings vs. monthly).
    • Billing-interval toggle (BillingIntervalToggle) is a small Monthly/Annual segmented control. Self-hides when annual_available=false from the config probe so monthly-only deployments render the same UX as before. Used inside GraphenePlusModal and the /dashboard/subscription non-subscriber branch. Lifted state — caller decides interval so the same value drives both the toggle UI and the checkout call.
    • Frontend modal: apps/frontend/src/components/graphene/GraphenePlusModal.tsx — two states (subscriber: shows renews/expires + "Manage subscription" → Customer Portal in new tab; non-subscriber: perks list + "Subscribe — $5/mo" → Stripe Checkout). Bounces anonymous users to /auth/signup?redirect= back to the trigger page. Surfaces: (1) per-chapter locked card + locked-row in ChapterPlaylistPlayer on /seasons/[id] (auto-advance into a locked chapter no longer plays — instead, the now-playing strip swaps the audio element for a Subscribe card); (2) "Graphene+ · $5/mo" pill in the /graphene hero, distinct from the existing free email-list "Get release emails" button.
    • RSS feed paywall (season-distribution.ts buildPodcastRss): novel-mode chapters now appear as per-chapter items in the public RSS feed, filtered by chapter_free_until IS NULL OR chapter_free_until > now() so chapters past their free window drop out of the public feed. Only chapters whose parent season has season_publishings.podcast_published_at set are emitted (avoids leaking unpublished novellas). Future: signed authenticated subscriber feed for full back-catalog access in podcast clients.
    • Letter priority (season-letters.ts runAutoSelectionRound): subscribers' pending letters bubble to the top of each persona's queue. Within a tier, oldest-first wins so submission order is honored. Subscription lookup batches submitter_user_id over an IN() query against the subscriptions table, applying the same grace-period rule as isGrapheneSubscriber.
    • PostHog events (in analytics.ts): graphene_plus_modal_opened (with source: 'locked_chapter_row' | 'locked_chapter_card' | 'hero_pill' | 'letters_priority' | 'dashboard' | 'subscribed_query_param' | ...), graphene_plus_checkout_started, graphene_locked_chapter_impression (with season_id + source so we can see whether the paywall actually hits people).
    • Server-side conversion events in services/analytics-server.tsposthog-node client wrapped with captureImmediate() so events flush before the webhook returns (the queue-based capture() may not flush in time for short-lived handlers). The Stripe webhook in payment.ts fires graphene_plus_subscription_active on checkout.session.completed (only when plan_key === 'graphene_plus') and graphene_plus_subscription_canceled on transitions: cancel_at_period_end false→true, status active→non-active, or customer.subscription.deleted. Transition detection reads the prior row before updating so renewal customer.subscription.updated pings don't re-fire the cancel event. Reads POSTHOG_KEY (falls back to NEXT_PUBLIC_POSTHOG_KEY since project keys are write-only and safe in either context) + POSTHOG_HOST; no-ops when neither is set so dev/CI environments aren't blocked.
    • Letter-priority perk surfaced on ListenerLetters.tsx: non-subscribers see a "Graphene+ subscribers' letters land at the top of the queue → Subscribe $5/mo" pitch chip wired to GraphenePlusModal with source='letters_priority'; subscribers see a quiet "Graphene+ priority" emerald badge confirming the perk applies.
    • Welcome-toast on /graphene?subscribed=1 (Stripe success_url): one-shot toast appears bottom-right, then the query param is stripped via history.replaceState so a refresh doesn't re-fire it.
    • Manage entry on /dashboard/subscription: Graphene+ card sits above the legacy Premium card; shows renews/expires + a Manage billing button wired to /api/payment/customer-portal (replaces the previous alert('coming soon') placeholder). Legacy card hides itself when the only active row IS the Graphene+ row to avoid double-rendering the same subscriptions row twice.
    • In-app cancellation flow + retention offer (CancellationModal): a discreet "Cancel subscription" link next to "Manage billing" opens a two-step modal (reason picker → optional 1-month-free retention offer → confirm). Three terminal outcomes hit distinct backend endpoints so subscription_cancellations outcome reflects the actual path: kept_with_offer (took the coupon), canceled (declined and confirmed), kept_without_offer (reconsidered after seeing offer). Reasons are controlled-vocab via CHECK constraint. Backend service subscription-cancellation.ts exports cancelSubscription, applyRetentionOffer, logKeptWithoutOffer. Required env: STRIPE_COUPON_RETENTION — the ID of a coupon you've created in the Stripe dashboard (e.g., 100% off for 1 month, duration repeating/months: 1). When unset, the offer step is skipped server-side and the modal drops straight from reason picker to cancel confirmation. The existing customer.subscription.updated webhook handler picks up the resulting state change and fires the graphene_plus_subscription_canceled analytics event — no extra wiring needed.
    • Public /graphene-plus marketing page (apps/frontend/src/app/graphene-plus/) — shareable, indexable URL separate from the in-app modal. Hero + 4 perks + "what's free / what's paid" comparison + 6-question FAQ + closing CTA. Click-to-subscribe still routes through GraphenePlusModal (with source='graphene_plus_landing') so the Stripe Checkout call stays in one place. Linked from the /graphene hero pill (the pill now navigates to this page rather than opening the modal directly — gives cold visitors the full pitch before clicking Subscribe). Page-level metadata for clean OG/Twitter unfurls.
    • Super-admin monetization dashboard at /dashboard/admin/monetization (page) — visual surface over the registry in services/monetization-pipelines.ts. Lists every monetization option from GRAPHENE_MONETIZATION.md (Phase 1 listener sub, Phase 2 Kindle/Audible, Phase 3 Studio SaaS + course + B2B, Phase 4 YouTube/podcast + adaptation/Affleck + sponsored shows + translation), each with: pitch, revenue mechanic, time-to-first-dollar, top risk, segmented progress bar (one sliver per step, tinted by status — emerald=shipped, amber=in_progress, slate=planned, red=blocked, zinc=deferred), step counts, and live metrics where available. Headline cards at the top show active Graphene+ subs + MRR (computed from subscriptions count × $5) + ARR + new subs / 28d + canceling count + paywalled-vs-free-window chapter counts. Backend route GET /api/admin/monetization/pipelines (super-admin only) returns the registry with computed progress + step_counts + sparse metrics (only listener_subscription has live numbers — every other pipeline is planned). Linked from the /dashboard/admin tab strip as 💰 Monetization. To update progress: flip a step's status in monetization-pipelines.ts to 'shipped'; the progress bar recomputes on next page load.
    • Growth sparklines + week-over-week deltas on the same page — daily snapshots of headline metrics in monetization_snapshots, captured every 6h by the monetization_snapshot_tick cron in apps/backend/src/index.ts (heartbeat-tracked). On first deploy the cron's empty-table check triggers a 90-day backfill that walks subscriptions.created_at minus subscription_cancellations (with outcome='canceled') so the chart is non-empty immediately rather than after a month of accumulation. Backend service services/monetization-snapshots.ts exports captureMonetizationSnapshot() (UPSERT-on-date, idempotent), backfillMonetizationSnapshots(), getMonetizationHistory(daysBack), and runMonetizationSnapshotTick() (cron entry point). Read endpoint GET /api/admin/monetization/history?days=90 returns the trailing series for the dashboard's inline-SVG <SparklinePanel> (no chart-lib dependency). Each headline metric card now shows a +N 7d / −$X 7d pill computed from history[length-8] vs history[length-1]; pill is suppressed when there's <8 days of data so a partial baseline doesn't mislead.
    • "Why people cancel" panel on the same page — bucketed reason breakdown with horizontal-bar splits (kept-with-offer / kept-without-offer / canceled / redirected) and a headline offer acceptance % so the retention coupon's effectiveness is at-a-glance. Reads from subscription_cancellations (intent log written by the in-app cancel modal). Backend getCancellationAnalytics(daysBack) in services/subscription-cancellation.ts returns total intents + outcome counts + per-reason buckets sorted by volume + recent rows for context. Read endpoint GET /api/admin/monetization/cancellations?days=90 (capability-gated like the other monetization routes). Panel hides entirely when no intents exist in the window so the page doesn't show empty scaffolding pre-launch.
    • Risks & blockers summary panel at the top of the same page — surfaces every step.status === 'blocked' (actionable today) plus every non-deferred pipeline's risk field (strategic) across all phases, so admins see what's currently in the way of growth without expanding every pipeline card. Pure UI rollup over the data already returned by /api/admin/monetization/pipelines; hidden when there are zero blockers and zero non-deferred risks.
    • Cohort retention table on the same page — for each calendar-month signup cohort, shows size + how many are still active right now + retention %. Backend getCohortRetention(monthsBack) in services/monetization-snapshots.ts groups subscriptions rows by month-of-created_at and counts ones still status='active'. Read endpoint GET /api/admin/monetization/cohorts?months=12. Color-codes retention by health (≥80% emerald, ≥50% amber, <50% rose). This is a current-state snapshot (not a per-month curve) — good enough to see which cohorts churned hardest; a real curve via cohort×snapshot join is feasible later.
    • ARR projection card on the same page — least-squares linear regression on the trailing-60-day MRR snapshot series projected forward 365 days, with implied lift vs current ARR + R² as a "trend strength" / confidence badge. Hidden when there's <30 days of data, all points are equal, or the slope is non-positive (a flat or shrinking projection isn't useful as a forward-looking metric). Pure frontend computation in page.tsx computeArrProjection(); no backend changes.
    • Conversion funnel panel on the same page — 4-stage horizontal-bar from locked_impressionmodal_openedcheckout_startedsubscribed plus a per-source breakdown (which entry surface — locked card, hero pill, support-writer modal, etc. — actually converts). Backed by monetization_funnel_events (a self-hosted mirror of the PostHog funnel events that already fire). Frontend trackProductEvent() in lib/analytics.ts dual-writes to PostHog AND to POST /api/monetization/funnel/event (auth-optional — anonymous funnel impressions are valid data). The terminal subscribed stage is server-side-only: written by the Stripe webhook in routes/payment.ts so a malicious client can't inflate the conversion rate. Backend service services/monetization-funnel.ts exports recordFunnelEvent() + getFunnelSummary() + getFunnelBySource(). Read endpoint GET /api/admin/monetization/funnel?days=90. Hidden when zero events have fired in the window.

Company expenses ledger — the money-OUT counterpart (/dashboard/admin/expenses)

The general money-out ledger, sibling to the monetization (money-in) dashboard. Before this, money-out was scattered across slices that each covered only part: marketing_spend (migration 205, acquisition-only + coupled to CAC), and tts_call_log / llm_call_log (auto-tracked VARIABLE API usage). Nothing captured FIXED/recurring infra (Vercel, Railway, Supabase, Resend, the $1000/mo ElevenLabs plan) or one-off business costs (LLC renewal, domains, legal). This is where everything money-out lives. Migration 323_company_expenses.sql.

  • Two tables (both RLS-enabled, no policies — service-role/super-admin only): company_expenses (the dated ledger — manual one-offs + rows materialized from a recurring definition, the latter stamped with recurring_expense_id) and company_recurring_expenses (definitions: vendor + category + amount + period monthly/annual + start_date + last_materialized_on). Category is free text (no CHECK) so new categories add from the UI without a migration; the app seeds infrastructure / api / email / business / domains / marketing / misc.
  • Recurring materializercompany_expenses_recurring_tick cron (24h, heartbeat-tracked, CRON_LABELS entry) calls materializeRecurringExpenses(todayStr) in services/company-expenses.ts: walks each active definition from last_materialized_on (or start_date) forward, inserting one ledger row per elapsed period boundary, advancing last_materialized_on. Idempotent via the (recurring_expense_id, expense_date) partial unique index; 60-period safety bound per definition per pass. So "Vercel $20/mo" is entered once and auto-logs monthly.
  • Computed metered-API linegetExpenseSummary(daysBack) sums tts_call_log.cost_cents + llm_call_log.cost_cents live over the window and returns it as a read-only metered_api_cogs_cents figure (with TTS/LLM breakdown) separate from the ledger, so the grand_total_cents is complete without duplicating per-call rows into the ledger.
  • Routes (super-admin, routes/admin.ts): GET /api/admin/expenses?days=90 (summary: rows + category rollup + metered line + grand total), POST /api/admin/expenses (manual row), DELETE /api/admin/expenses/:id, GET|POST /api/admin/expenses/recurring, PATCH /api/admin/expenses/recurring/:id (pause/resume via { active }).
  • UI — totals cards (ledger / metered API / grand total / active recurring count), category breakdown bars, a one-off-expense form + a recurring-definition form, the recurring-definitions list with pause/resume, and the dated ledger table (materialized rows tagged "recurring", not individually deletable). Linked from the admin landing page's money cluster next to Monetization. Net-against-revenue is deferred — for now it links to /dashboard/admin/monetization for the money-in side.

Cold reads — how an outside AI describes graphene.fm over time (/about/cold-reads)

A public historical timeline: each month we run a fixed set of neutral, web-grounded prompts against graphene.fm — with no context, not telling the model it's ours — and publish the unedited responses. Radical-transparency artifact ("Written. Spoken. Yours."); the longitudinal value is watching a cold read drift from sparse → rich as web presence grows. Migration 324_cold_reads.sql.

  • Prompt registryCOLD_READ_PROMPTS in services/cold-reads.ts: positioning ("what is this & who competes") + tam ("what's it worth, TAM"). Each is a question_key with its own timeline; add a question by appending to the registry (no migration — question_key is free text). Keep prompts FIXED so the longitudinal comparison holds; the per-row stored prompt makes any change auditable.
  • Web grounding — the chat shim (llm.ts) has no browsing, so runWebGroundedPrompt() calls the OpenAI Responses API directly with the built-in web_search tool (model gpt-4o). Failure-isolated: a failed capture stores a row with the error instead of throwing, so the cron heartbeat + admin view surface it. (First live run should verify the web_search tool response shape against the current API.)
  • Croncold_read_capture_tick (24h tick, ~28d cadence per question via a freshness gate, heartbeat-tracked + CRON_LABELS). Captures with status='captured'.
  • Publish gate — every read is captured hidden; an admin flips it to published before it shows publicly (protects the public surface from an off / wrong take). Plus a manual-add path (addManualColdRead) for logging reads run by hand in ChatGPT/Claude/Gemini, tagged with the tool as source — this is how the 2026-05-28 baseline reads get in.
  • Tables/RLScold_reads (RLS enabled, no policies). Public reads go through GET /api/cold-reads (published only, service-role) in routes/cold-reads.ts; admin controls (GET /api/admin/cold-reads, POST .../capture, POST .../manual, PATCH .../:id/status) in routes/admin.ts.
  • UI/about/cold-reads: per-question timelines, newest first, unedited. Super-admins additionally get inline Capture-now buttons, a paste-an-external-read form, and per-read Publish/Hide.
    • OG card + per-volume metadata for shared best-of links (api/og/cafe-best-of/[slug] + server-shell page.tsx for /write-cafe/best-of and /write-cafe/best-of/[slug]): each published volume gets a unique 1200×630 OG card (paperback-feeling amber gradient, big serif title, edition label, story count + run-time, "📕 on kindle" pill when ASIN is set). The /write-cafe/best-of/[slug] page was split into a server shell (page.tsx with generateMetadata that fetches volume data at the edge) + client island (BestOfVolumeClient.tsx) so social shares unfurl with the per-volume card on Twitter / Bluesky / iMessage. List page also got a server shell with static metadata.

    • Public Best-of reader (routes/cafe-best-of.ts + /write-cafe/best-of + /write-cafe/best-of/[slug]): public-facing surface for the curated compilations. Two pages — list view at /write-cafe/best-of rendering each published volume as a card (cover-or-typographic, edition label, story count, total run-time, "on kindle" pill when amazon_asin is set), and a single-volume reader at /write-cafe/best-of/[slug] showing one long scrollable read with hero + cover image + table of contents + every story rendered inline (editor's note, position, title, writer credit linked to /writers/[handle], inline <audio controls> when audio_url present, full prose body in serif, ❦ separator between stories). Closing CTA pushes to Kindle when ASIN is set, with the canonical FTC Amazon Associate disclosure. Endpoints: GET /api/cafe/best-of (list — published volumes only, drafts/archived stay admin-only) + GET /api/cafe/best-of/:slug (full detail with stories joined). Cross-link added to the /write-cafe hero pill row as "📚 Best of".

    • Best-of curation tool (migration 196 + services/cafe-best-of.ts + /dashboard/admin/cafe/best-of): admin builds a "write.cafe Best Short Stories Vol. N" compilation by picking from the candidate winners pane and arranging them on the in-volume pane (drag-via-arrows reorder, optional per-story editor's note). Two tables: cafe_best_of_volumes (slug, name, description, status, edition_label, amazon_asin, cover_image_url) + cafe_best_of_stories (volume_id, submission_id, position, editors_note, unique on both [volume_id, submission_id] and [volume_id, position]). Privacy floor matches the rest of the cafe-stories pipeline — only WINNING submissions can be added; setVolumeStories() validates against cafe_contest_winners and rejects non-winners with the offending IDs in the error. Lifecycle: draftpublished → optional archived. Once a volume is published, story-set edits are blocked at the API layer (a shipped Kindle book can't silently change content) — admin must "Reopen as draft" first. Endpoints: GET/POST /api/cafe/admin/best-of/volumes, GET/PATCH/DELETE /api/cafe/admin/best-of/volumes/:id, GET /api/cafe/admin/best-of/volumes/:id/candidates (every winner with an in_volume flag), PUT /api/cafe/admin/best-of/volumes/:id/stories (replace whole story-set, idempotent — UI keeps a local list, ships the new ordering whole). The next step in the registry is kdp_compilation_export — programmatic .epub generation from a published volume's story-set.

    • ACX-prep audio bundle (services/cafe-best-of-audio-bundle.ts + admin endpoint in routes/cafe-admin.ts + Export ACX zip button on /dashboard/admin/cafe/best-of): per-chapter MP3 download bundle for Audible's ACX platform. Each story is fetched from its audio_url, padded with 0.5s header silence + 2s trailer silence (ACX requires both), re-encoded mono 192 kbps CBR via ffmpeg, named in chapter order (01-{slug}.mp3, 02-…), and zipped with a manifest.txt listing chapters + run-times + the ACX submission checklist (RMS norm, peak limit, cover art spec, author credits). Stories without audio_url are reported in the missing list (admin should run "Render missing audio" first, then re-bundle). Drafts can't be exported. Endpoint GET /api/cafe/admin/best-of/volumes/:id/audio returns the zip with X-Story-Count / X-Bundled-Count / X-Missing-Count / X-Total-Duration-Seconds headers so the UI can surface a "with N missing" warning. RMS normalization + cover-art generation are deferred to admin's Audacity QA pass pre-submission.

    • KDP-spec EPUB export (services/cafe-best-of-epub.ts + admin endpoint in routes/cafe-admin.ts + Export button in /dashboard/admin/cafe/best-of): generates a valid EPUB 3.0 from a published volume, ready for KDP upload (Amazon accepts .epub directly since 2024 — no .mobi conversion needed). Built with adm-zip (already in deps) — no new heavyweight epub-gen library. Structure includes the spec-mandated mimetype-first-stored entry, META-INF/container.xml, OEBPS/content.opf with full Dublin Core metadata + per-writer <dc:creator> entries + EPUB 3 manifest/spine, nav.xhtml for the navigation, cover.xhtml (with optional fetched cover.jpg from cover_image_url, falling back to a typographic title page), styles/book.css for serif typography matching the public reader, one chapters/chapter-N.xhtml per story with editor's note + byline + body (paragraphs detected from blank-line splits, single newlines preserved as <br/> for poetry/dialogue layout), and bios.xhtml aggregating writer bios with their contributed stories at the back. Endpoint GET /api/cafe/admin/best-of/volumes/:id/epub returns the binary; refuses to export drafts (a shipped manuscript can't silently change). Frontend Export button only appears when status=published.

    • Writer-facing self-toggle UI for opt-in visibility (/write-cafe/my-entries + endpoints in routes/write-cafe.ts): completes the opt-in path so writers don't need an admin to publish a non-winning entry. Page lists every cafe-contest submission the signed-in writer has made (auth-required, server enforces writer_user_id = auth.uid()), with placement badges for wins, body preview, contest week, and a Show on my profile checkbox per row that hits PATCH /api/write-cafe/my-entries/:id/public. Winners always render with an "always public" note instead of a toggle (winners surface automatically). View-public link jumps to the /writers/[handle]/stories/[id] page using the writer's claimed username (or user_id fallback). Cross-linked from the cafe hero as "📝 My contest entries" — only visible when signed in.

    • Writer opt-in for non-winning entries (foundation) (migration 197 + read-side updates in routes/writers.ts + admin PATCH in routes/cafe-admin.ts): adds a public_on_profile boolean default false column to cafe_contest_submissions so a writer (or admin acting on their behalf) can flip individual non-winning entries to public visibility on their /writers/[handle] profile. Per-submission rather than profile-level so writers can publish some non-winners but not others. Read-side merges (winners) ∪ (public_on_profile=true) — opt-in non-winners appear with placement: null in the API payload so the UI can distinguish them from winning entries. Admin endpoint PATCH /api/cafe/admin/submissions/:id/public-on-profile body { public: boolean } flips the flag. Writer-facing self-toggle UI is still planned (opt_in_loser_visibility registry step is in_progress).

    • Auto-render-on-win cron (apps/backend/src/index.ts cafe_story_audio_tick block + runCafeStoryAudioTick() in services/cafe-stories-audio.ts): heartbeat-tracked cron firing every 6h that picks up newly-winning submissions without an admin click. Calls renderAllMissingCafeStoryAudios(), which already fail-soft logs per-row failures to service_errors so a silent batch death surfaces at /dashboard/admin/errors. Skips entirely (with skipped_reason) when ELEVENLABS_API_KEY isn't configured. Cost-bounded: only winners (~1-3/week) × ~$0.72 per 700-word render = ~$2.20/week max. Heartbeat label cafe_story_audio_tick registered in CRON_LABELS so the system-health dashboard shows it.

    • ElevenLabs narration for cafe-contest winners (migration 195 + services/cafe-stories-audio.ts + admin endpoints in routes/cafe-admin.ts): mirror of short-stories-audio.ts for the cafe-contest winner pipeline. Adds audio_url / audio_duration_seconds / audio_voice_id / audio_generated_at to cafe_contest_submissions (winning rows are the only ones the renderer accepts — assertWinningSubmission() belt-and-suspenders the privacy floor for direct callers). Storage prefix is season-assets/cafe-stories/<submission_id>.mp3; same single editorial voice (SJ_ANDERSON_VOICE_ID env var, fallback "Sarah"). Cost ~$0.72 per 700-word story. Two admin endpoints: POST /api/cafe/admin/cafe-stories/:submissionId/render-audio (single, idempotent on audio_url, ?force=1 to re-render) + POST /api/cafe/admin/cafe-stories/render-missing (bulk-render every winner without audio, sequential, errors logged to service_errors). Story detail page at /writers/[handle]/stories/[storyId] renders an inline <audio controls> player when audio_url is set, with duration formatted alongside; the writer-profile tile gains a "🎧 listen" chip.

    • AI cover images for short stories (migration 200 + services/story-cover-images.ts + admin endpoints in routes/cafe-admin.ts + routes/short-stories.ts + story_cover_tick cron in apps/backend/src/index.ts): DALL-E 3 1024×1024 cover images for both cafe_contest_submissions (winners) and short_stories (sj-anderson). Two-step generation — gpt-4o-mini composes a DALL-E-friendly prompt from title + opening (avoids faces / typography / cluttered scenes; goes for atmospheric editorial illustration), then DALL-E 3 renders. Image is re-hosted in Supabase storage at season-assets/story-covers/<id>.png so the URL doesn't expire. Cost ~$0.04/cover (DALL-E standard quality + ~$0.0002 prompt compose). Heartbeat-tracked 6h cron story_cover_tick auto-renders any winner / sj-story without a cover; admin endpoints POST .../render-cover (single, idempotent on cover_image_url, ?force=1 to re-render) + POST .../render-missing-covers (bulk) provide manual triggers. Frontend rendering: hero cover on the story-detail pages (/writers/[handle]/stories/[id] + /sj-anderson/[id]) + a small left-rail thumbnail (~80×80) on the list views (writer profile cafe-winners + sj-collection sections, sj-anderson reading page). The DALL-E cover takes priority over the legacy iTunes / bento album art when both exist.

    • /sj-anderson unified with the writer-page surface (migration 199 + services/sj-anderson-owner.ts + boot-backfill in apps/backend/src/index.ts + render integration on /writers/[handle]/page.tsx + cross-link on SJAndersonClient.tsx): "SJ Anderson" is reframed as the editorial pen-name of a real platform user (sandonjurowski@gmail.com by default, configurable via SJ_ANDERSON_OWNER_EMAIL env var). Migration 199 adds nullable author_user_id to short_stories; on backend boot, backfillSjAndersonOwner() idempotently sets the column for all unattributed rows. The tournament-create path stamps author_user_id on every new winner via getSjAndersonOwnerUserId(). Writer page at /writers/[handle] now surfaces a "SJ Anderson collection" section beneath the cafe-winners section showing the owner's curated short stories with chapter number + audio chip + body preview, linking to the existing /sj-anderson/[id] detail pages. /sj-anderson keeps its rich list page (audio + prompt context) and gets a small "Curated by [name] · See full author page →" attribution under the hero linking to /writers/[handle]. New endpoint GET /api/short-stories/owner returns the resolved owner's handle + display_name for the cross-link.

    • Pull-quotes (auto + curated) on every short-story surface (migration 198 + services/pull-quotes.ts + admin endpoints in both routes/cafe-admin.ts and routes/short-stories.ts + writer self-set in routes/write-cafe.ts + shared component components/cafe/PullQuoteBlock.tsx): magazine-style pull-quotes — short evocative lines surfaced as styled aside blocks above the story body. Two storage slots per story: auto_pull_quotes (LLM-extracted, gpt-4o-mini, ~$0.0003/story) + pull_quotes (curated by writer or admin). Renderer prefers curated when present, falls back to auto. Verbatim-only — both the LLM and the writer self-set endpoint length-clamp to 30–240 chars and reject anything that isn't a verbatim substring of the body (no paraphrasing, no mis-attribution). Applied to both cafe_contest_submissions (winners) and short_stories (sj-anderson). Writer can edit their own curated set from /write-cafe/my-entries via an inline 3-slot editor that seeds from auto-picks. Admin endpoints: POST /api/cafe/admin/cafe-stories/:id/extract-pull-quotes + /extract-missing-pull-quotes (bulk) + PATCH /pull-quotes (set curated) for cafe submissions; mirror endpoints under /api/short-stories/admin/* for sj-anderson.

    • AI-persona critiques on writer's own short stories (migration 202 + services/story-critiques.ts + endpoints in routes/write-cafe.ts + UI in /write-cafe/my-entries/page.tsx): Cafe Pro / super-admin perk — writers request 200-400 word in-voice critiques of their submitted stories from ai_personas. Prompt seeds the LLM with the persona's display_name + MBTI + backstory.core_tension + backstory.voice so the critique reads as that character (first person, opinionated, must verbatim-quote 1-2 lines from the story). Polymorphic story_critiques table with target_kind discriminator across cafe_submission + short_story; persona_id is ON DELETE SET NULL and persona_display_name is mirrored at write-time so retired personas still attribute correctly. Persona selection: caller-specified or random fresh persona that hasn't yet critiqued this story (so successive requests yield different perspectives). Cost ~$0.0008 per critique (gpt-4o-mini, max_tokens 700, ~4000-char body slice with truncation acknowledged in the prompt). Endpoints: GET /api/write-cafe/my-entries/:id/critiques (writer's own — un-gated, returns critiques + active persona list) + POST /api/write-cafe/my-entries/:id/critiques (gated to super-admin OR isWriteCafePro — friendly 403 with subscribe prompt otherwise). Frontend exposes a "🎭 Critique" toggle on each row in /write-cafe/my-entries; the inline CritiquesPanel lets the writer pick a persona (or "any free persona"), fires the request, and renders critiques in serif italic with persona name + MBTI + timestamp.

    • Featured critique blurbs on the public story page (migration 203 + setCritiqueFeatured + listFeaturedCritiquesForStory in services/story-critiques.ts + PATCH /api/write-cafe/my-entries/:id/critiques/:critiqueId/feature + featured_critiques payload on GET /api/writers/:handle/stories/:storyId + "Editorial impressions" section on /writers/[handle]/stories/[storyId]/page.tsx): writers turn the persona critiques they requested into back-cover-style blurbs on their public story-detail page. Two new columns on story_critiques (is_featured boolean default false + featured_excerpt text) plus a partial index on featured rows. Excerpt has to be a 30–280 char verbatim substring of the critique body — service-layer check rejects paraphrasing. Per-row UI in CritiquesPanel ("★ Feature" → textarea → "Save blurb"); promoted critiques highlight in amber and surface on the public page as italic figures with persona name + MBTI attribution. Un-featuring is one click. Foundation for using critique excerpts as marketing copy / Kindle-anthology back-cover text.

    • "Critics on the floor" — featured critiques aggregated on the writer-page hub (listFeaturedCritiquesForWriter in services/story-critiques.ts + featured_critiques payload on GET /api/writers/:handle/stories + new section on /writers/[handle]/page.tsx): one level up from the per-story "Editorial impressions" panel — featured critique excerpts across the writer's whole portfolio (cafe submissions + sj-anderson short stories) collapse into a 2-column grid on the writer-page hub. Each tile shows the verbatim quote in serif italic + persona display name + MBTI + a link back to the source story ("on 'The Stranger at the Counter' →"). Capped at 6 most recent so the panel reads as curated, not a wall. Aggregator does two IN-list queries against the polymorphic story_critiques table (one per target_kind), joins persona MBTI in one round-trip, and is skipped entirely when the writer has no featured blurbs (no empty-state rendered). Cross-portfolio editorial reception in one glance — useful for both the listener-discovery funnel and Kindle-anthology marketing.

    • "From the floor" — community featured critiques on /write-cafe (listRecentFeaturedCritiquesPublic in services/story-critiques.ts + public GET /api/cafe/contests/featured-critiques in routes/cafe-contests.ts + new components/write-cafe/FromTheFloor.tsx rendered on /write-cafe/page.tsx for both signed-in and signed-out paths): one level up from the writer-page hub — featured critique blurbs across the whole community surface on the cafe homepage. Aggregator pulls a 4× pool, filters to publicly-visible source stories (cafe winners + opted-in submissions + published short_stories), joins writer profile + persona MBTI, returns up to N (default 6) most recent. 5-min HTTP cache + stale-while-revalidate. Each floor tile shows the verbatim quote, persona name + MBTI, story link, and writer link — drives discovery between writers and lets free-tier visitors taste the critique feature before subscribing. Empty community falls back to a compact "Meet the critics" promo pointing at /write-cafe/personas.

    • Floor cold-start seeding (services/cafe-floor-seed.ts + super-admin POST /api/cafe/contests/seed-floor in routes/cafe-contests.ts + 🎭 "Seed the floor" button on /dashboard/admin/cafe/contests): rather than wait for the first writer to request + feature their own critique, super-admins can seed the floor on demand. Per pass: walks the most-recent N published SJ Anderson short stories that don't yet have a featured critique, generates a real critique from a random active persona via requestCritique(), then runs pickFeaturableExcerpt() over the body to pick the strongest sentence-shaped 30–280-char substring (weighting longer sentences higher, penalizing questions / mid-flow conjunctions / hedge phrases like "I think") and features it via setCritiqueFeatured(). Cost: ~$0.0008/story (gpt-4o-mini). Idempotent — stories that already have a featured critique are skipped. Errors per story are caught + recorded to service_errors ('cafe-floor-seed' service tag) so a single LLM hiccup doesn't kill the batch. Use case: ship a fresh write.cafe with the floor pre-populated, then top up periodically as the SJ Anderson catalog grows.

    • Floor auto-seed cron (runCafeFloorSeedTick in services/cafe-floor-seed.ts + loop in apps/backend/src/index.ts + cafe_floor_seed_tick label in services/system-health.ts): companion to the on-demand admin button — recurring task that auto-tops-up the floor without manual intervention. Default cadence: 24h tick × 2 stories/tick = ~$0.05/month worst case. Env-gated via CAFE_FLOOR_SEED_ENABLED=true so prod ramps deliberately. Tune CAFE_FLOOR_SEED_INTERVAL_HOURS (default 24) + CAFE_FLOOR_SEED_PER_TICK (default 2) if you want it more aggressive. Eventually saturates: once every public SJ Anderson story has a featured critique the tick becomes a no-op until new content lands. Heartbeat-tracked, surfaces on /dashboard/admin/system-health. requested_by_user_id is null on cron-generated critiques (the column is nullable per migration 202) — distinguishes them from admin-driven seeds if you ever want to audit.

    • Critique marketplace — write.cafe human editors for Graphene chapters (docs/ai/features/critique-marketplace.md — full multi-phase roadmap + decisions). Polymorphic story_critiques (target_kind: cafe_submission / short_story / story_episode) is the long-running spine; T0 single-shot AI critiques + T0.5 manual write.cafe queue + T0.6 angle-based AI assist for editors + T1.1 persona picker + T1.2/T1.3 four-specialist Critique Pass all live on /seasons/[id]/read via ChapterCritiquesPanel. T2.1 (migration 340 — is_editor capability flag + editor_profiles): editor application at /editor/apply, applicant status + queue at /editor, super-admin review console at /dashboard/admin/editor-applications, services in services/editor-profiles.ts, routes mounted at /api/editor in routes/editor.ts. T2.2 (migration 341 — editor_commissions table + story_critiques.editor_user_id / commission_id): public marketplace browse at /editors, in-context commission picker EditorPickerModal opened from the chapter panel's 🤝 Commission button, editor's incoming queue with accept / decline / deliver flow extended onto /editor, services in services/editor-commissions.ts (lifecycle: pending → accepted | declined; accepted → delivered | cancelled; rate snapshot at booking is forward-compat for T2.3 Stripe Connect). v0 is free both sides; payouts ship in T2.3.

    • AI-persona critiques on /sj-anderson stories (super-admin) (admin endpoints in routes/short-stories.ts + featured_critiques in public GET /api/short-stories/:id + admin <ShortStoryCritiquesPanel> + public "Editorial impressions" section on StoryDetailClient.tsx): completes the polymorphic claim of story_critiques — same service, same table, target_kind='short_story', no new schema. Admin endpoints GET /admin/:id/critiques + POST /admin/:id/critiques + PATCH /admin/critiques/:critiqueId/feature (super-admin only); the feature endpoint validates that the critique target is a short_story so an admin can't accidentally feature a cafe-submission critique through this path. Frontend: every visitor sees the public "Editorial impressions" panel under the body when at least one critique is featured; super-admins additionally see an inline ShortStoryCritiquesPanel for requesting + featuring without leaving the page. Featured blurbs from /sj-anderson critiques flow through the same "Critics on the floor" (writer hub) + "From the floor" (cafe homepage) aggregators — the polymorphic infra is one wire-up, not two.

    • OG cards with featured critique excerpt on per-story share URLs (/api/og/writer-story/[handle]/[storyId]/route.tsx + upgraded /api/og/sj-anderson-story/[id]/route.tsx + new server-shell on /writers/[handle]/stories/[storyId]/page.tsx with the client moved to WriterStoryClient.tsx): each per-story share now unfurls with a bespoke 1200×630 card on Twitter / Bluesky / iMessage. Two-column layout: cover art (DALL-E story cover when minted, falling back to bento song cover for SJ Anderson or a typographic placeholder for cafe winners) on the left; on the right, title + writer/author byline + a featured persona critique excerpt with persona name + MBTI attribution (real quote sells the share). Falls back gracefully — pull-quote when no critique is featured, then theme line, then word count. The writer-story page was previously a pure 'use client' component with no per-story OG; now it has a server shell with generateMetadata that fetches story + writer + featured critique at the edge (10-min revalidate), dynamic description leads with the blurb when one exists. Direct extension of the critique-cascade work: a writer who features a Hugo or Beatrice line gets that line carried into every social share.

    • Writer-hub OG card on /writers/[handle] (/writers/[handle]/layout.tsx + /api/og/writer/[handle]/route.tsx): the layer above per-story OG — the writer's hub URL itself now unfurls with their display name + battle stats (W/L) + clout tier glyph + most-recent featured critique excerpt + persona attribution. 1200×630 card: hero image or avatar (with a typographic display-name initial as fallback) on the left, "✒ Writer" eyebrow + serif display name + stats row + blurb on the right. Stats row only renders segments that have data so fresh writer hubs don't read with empty slots. Implemented as a thin layout.tsx with generateMetadata (rather than a server-shell page split) since the writer-page client is 786 lines of auth-aware modals + filters; layout pattern adds metadata without touching the existing client. Story-detail pages keep their per-story generateMetadata (which takes precedence on those subroutes). Cascades cleanly across the share funnel: graphene shelf card → writer hub OG → per-story OG → featured critique excerpt threading all three.

    • Cafe Stories → Kindle pipeline (top-level summary) — full flow + what's left to ship Vol. 1 documented at docs/product/CAFE_STORIES_TO_KINDLE.md. Six writer-side automated steps (submit → review → win → public author surface → audio narration → opt-in for non-winners) feed two one-click admin steps (curate Vol. N + export .epub + ACX zip), then real-world ops (KDP/ACX accounts, royalty contract, Audacity QA, cover art, upload + stamp ASIN). Each technical sub-feature is documented in detail below. Registry pipeline cafe_stories_to_kindle at services/monetization-pipelines.ts tracks per-step status; 11 of 12 technical steps shipped, last one (first_volume_published) is the real-world publish. Active task list for the remaining ops work lives in product_tasks under category "Cafe → Kindle" — visible at /dashboard/admin/tasks.

    • Cafe winners on public writer pages + per-story detail page (routes/writers.ts + /writers/[handle]/page.tsx + /writers/[handle]/stories/[storyId]/page.tsx): every weekly write.cafe contest winner now has a public surface. Privacy floor is "winner-only" — only submissions referenced by cafe_contest_winners are exposed; non-winning entries stay private under the existing self-read RLS on cafe_contest_submissions. New endpoints: GET /api/writers/:handle/stories (list) + GET /api/writers/:handle/stories/:storyId (detail). Writer profile renders a "From write.cafe" section beneath their seasons grid with placement badges (🥇/🥈/🥉/place N), contest week, theme, and category (AI-assisted vs. typed-only). Per-story page shows full prose with cafe-amber styling + an audio-narration placeholder card (planned to use the existing short-stories-audio.ts pipeline). This is the foundation for the cafe_stories_to_kindle pipeline (Phase 1, status=building) — annual "write.cafe Best Short Stories Vol. N" Kindle compilations from these winners.

    • Public /write-cafe-pro marketing page (apps/frontend/src/app/write-cafe-pro/) — shareable, indexable URL separate from the in-app WriteCafeProModal. Hero + 5 perks + "what's free / what's paid" comparison + 6-question FAQ + closing CTA. Uses the <CafeBackdrop> (fireplace) for visual continuity with /write-cafe. Click-to-subscribe routes through WriteCafeProModal so the Stripe Checkout call stays in one place. Page-level metadata for clean OG/Twitter unfurls.

    • write.cafe Pro foundation ($3/mo daily-ritual subscription) — backend service services/write-cafe-pro.ts (isWriteCafePro + getWriteCafeProSubscription, mirrors the Graphene+ pattern with plan_key='write_cafe_pro' on the shared subscriptions table), price constants in constants/graphene.ts and frontend mirror lib/write-cafe-pro.ts, payment endpoints in routes/payment.ts (POST /api/payment/write-cafe-pro/checkout, GET /api/payment/write-cafe-pro/status, GET /api/payment/write-cafe-pro/config), and the existing webhook's priceToPlanKey() recognizes STRIPE_PRICE_WRITE_CAFE_PRO. Frontend WriteCafeProModal renders the perks + Subscribe / Manage flow, opened from the new "✨ Cafe Pro" pill on the /write-cafe hero. Manage delegates to the same Stripe Customer Portal that Graphene+ uses (single endpoint, both plans). Anonymous visitors get bounced through /auth/signup?redirect=.... Required env to actually charge: STRIPE_PRICE_WRITE_CAFE_PRO — create a $3/mo recurring price in the Stripe dashboard and set the env var on Railway. Without it, the checkout endpoint 503s and the modal renders a "not yet configured" notice instead of the Subscribe button. Pro is purely additive — gated features (longer history, premium prompts, larger sprint rooms) are tracked as separate planned steps in the registry pipeline write_cafe_pro.

    • A/B-test scaffold layered on the funnel (migration 194 + services/experiments.ts + routes/experiments.ts + lib/useExperimentVariant.ts): admins create an experiments row with a variants jsonb (each variant has key, name, weight, and a config blob the frontend reads); the useExperimentVariant(key) hook resolves to one sticky variant per visitor (anon_id pre-auth, user_id post-auth — never re-bucketed) and exposes the variant's config so components can render variant-specific copy/labels. Funnel events thread experiment_key + variant_key through their metadata so the dashboard's Experiments panel renders per-variant assignment + funnel counts + conversion rate, with a "leading" pill on the variant with the highest conversion (gated to ≥5 modal opens to avoid declaring a winner off one sub). Initial wired experiment is lock_card_copy_v1 on the LockedChapterCard — variant config exposes title, body, cta strings that override the control. Public route GET /api/experiments/:key/variant?anon_id=... returns the sticky assignment. Admin routes: GET /api/admin/experiments (list with results), POST /api/admin/experiments (create), PATCH /api/admin/experiments/:key/status, POST /api/admin/experiments/:key/conclude. Admin UI: CreateExperimentModal on the monetization dashboard handles experiment creation (key + name + description + N variants, each with key/name/weight/config-as-JSON), and the per-row Experiments panel exposes ▶ Run / ⏸ Pause / ✓ Conclude (with a winner-picker pre-filled to the leading variant) / ↩ Reopen so the full lifecycle is clickable without SQL.

  • Visual identity: hex-lattice (graphene's carbon structure) — purple/indigo accents on dark #060610 background. No DALL-E logo yet; typographic mark only.
  • Audience comments → next-render feedback loop: listeners can drop timestamped notes on the season's audio player. Comments are stored in season_audio_comments (migration 096) with applied_at = null until the next Stage 3 (script regen). At regen time, fetchPendingComments() pulls them and formatCommentsForPrompt() formats them as an "AUDIENCE FEEDBACK" block injected into the GPT system prompt — so the new narrator copy can incorporate phrasing/pacing/tone notes. After successful regen, markAllPendingApplied() stamps applied_at so they don't double-count. Comments stay visible (with ✓ applied badge) for transparency. Workflow: drop comment → regenerate Stage 3 → Stage 4 (only segments with cleared audio_url re-render).
  • Per-aspect feedback (👍 / 👎): listeners can react to narrator / tone / music / word_choice at any timestamp via the FeedbackBar embedded inside the player. Stored in season_feedback (migration 103). Same regen lifecycle as comments — pending feedback gets injected into the next Stage 3 prompt as aggregate counts + specific timestamps where listeners thumbed down each aspect, then applied_at-stamped after success.
  • Auto-rerender on threshold: story_seasons.auto_rerender_threshold (migration 105) is an opt-in integer per season. A backend setInterval cron in apps/backend/src/index.ts calls runAutoRenderScan() every 4 hours; it counts cast-member journal entries created since the last successful audio event, and fires runFullPipeline() when the count meets the threshold AND >=4h have passed since the last fire (cooldown). Admin badge "📈 N new entries since last render" tracks the counter live; GET /:id/render-status powers it.
  • Stuck-event auto-cleanup: GET /:id/pipeline-events calls autoCleanStuckEvents(seasonId, 30) before returning, marking any 'started' row older than 30 min as failed. Handles container restarts / OOMs / Replicate timeouts that kill background workers before tracker.complete() / tracker.fail() can fire. Per-event manual ✕ Cancel button via PATCH /:id/pipeline-events/:eventId/cancel for explicit cleanup.
  • Freshness highlights: when any publishing artifact (audio_url, video_url, music_scored_at, video_rendered_at, updated_at) was touched within 7 days, both /graphene poster cards and /seasons/[id] player card show "✨ New" (within 24h, emerald accent + glow shadow) or "✨ Fresh" (within 7d, purple). Helps listeners notice re-cuts after auto-rerender fires.
  • Cinematic shot vocabulary (Stage 5): split-frame portrait+journal text on paper bg, split-screen on speaker change, mandelbrot lavfi ambient transitions, episode title cards, audio waveform mirrored across the bottom, lower-third name captions, mood-tuned color grading (8 mood recipes × 3 intensity levels), vignette + film grain finish. All toggleable per season via story_seasons.video_settings JSONB (migration 104) — defaults all on. Inter Bold font auto-fetched from jsdelivr's GitHub mirror on first render and cached in /tmp.
  • Per-segment voice direction (migration 099): season_audio_segments.voice_direction text key resolves to ElevenLabs voice_settings presets (default / emotional / calm / whispered / theatrical / crisp). Picker visible inside the now-playing card on /seasons/[id] for super-admins, alongside the voice override picker — change happens immediately, applies on next render.
  • Voice previews: ElevenLabs /v1/voices preview URLs cached in module memory, returned with each voice in /voice-bank. Edit Voices modal renders a ▶ play button per cast member that streams the preview; click again to stop.
  • Lore: Dream Roles — when a persona is currently NOT being told in an active season storyline, but has been cast in any season (past, completed, draft, paused), ~20% of their random journal entries weave in a vivid dream where they played that role. Conceit: their banal everyday life is real; their dreams are Graphene productions. Implementation: getDreamRoleContext() returns a "DREAM HOOK" prompt fragment with one randomly picked cast role + season metadata + role-specific dream tone (guilty=heavy chest, investigator=piecing things together, victim=can't move, witness=saw what you shouldn't have, suspect=everyone watching). The fragment instructs GPT to drop a 3-5 sentence dream paragraph into an otherwise normal entry — naturalistically, without explaining or interpreting. Trigger lives in generateJournalEntry() right after getActiveSeasonContext returns empty.
  • Magazine-style pullquotes on the novel reader (migration 131_chapter_pullquotes.sql): adds story_episodes.pullquotes (JSONB array of { text, after_paragraph_idx }) + pullquotes_generated_at. Service season-novel-pullquotes.tsgenerateChapterPullQuotes(seasonId, episodeNumber) calls gpt-4o-mini (~$0.001/chapter, ~2-5s) with the chapter's prose paragraph-numbered, asks for 1 quote (5-19 paragraphs) or 2 quotes (≥20 paragraphs), and validates: sentence must appear verbatim in the prose, 8-25 word sweet spot, no dialogue (skip sentences with quote marks), after_paragraph_idx clamped in-range. Auto-fires fire-and-forget after promoteChapterTake (prose changed → quotes are stale; LLM hiccup never fails the promote). Manual override: POST /api/story-seasons/:id/episodes/:n/regenerate-pullquotes (super-admin) — for picking a different line without re-promoting the take. The GET /:id/chapters endpoint surfaces pullquotes per chapter so the public read page renders them as oversized italic serif blocks between paragraphs (decorative " above + hairline rule below) — resolvePullQuotes() in ReadClient.tsx prefers server-stamped quotes and falls back to a client-side heuristic so legacy chapters keep showing one quote until their next take promote. Admin UI: 🪶 Pick pullquotes / 🪶 Regen pullquotes (N) button per chapter row in EpisodesAdminModal.tsx, shown only for chapters with cached prose; count badge reflects current value.
  • Listener Letters — entry footer (closes the loop visibly): when a journal entry rendered on /seasons/[id]/read was shaped by a Listener Letter (i.e. the entry's id appears as used_in_entry_id on a status='used' letter row), the page renders a small italic footer beneath the entry: "📨 inspired by a letter from @reader_handle" (or "a reader" for anonymous submitters), with the letter's content excerpt as a hover tooltip. The /api/story-seasons/:id/timeline endpoint enriches each entry with shaped_by_letter: { id, submitter_handle, content_excerpt } via a single batched query against season_listener_letters. This is what makes the Listener Letters loop visible to non-letter-writers — the audience's responsiveness becomes a public artifact, not just an email payoff. Skipped on the actor-profile entry list to keep scope tight; can be added there if desired by extending that endpoint with the same join.
  • Listener Letters (audience-agency loop) — subscribers write a short message to a specific cast persona; if selected (admin or auto), the next AI-generated journal entry from that persona is colored by it (no quoting, no fourth wall break). Submitter gets an email when their letter airs. Schema in migration 106_season_listener_letters.sql (status pending|selected|used|rejected, addressed_persona_id, OpenAI moderation snapshot, rate-limited by email + IP). Service apps/backend/src/services/season-letters.tssubmitLetter() (validates + moderates + rate-limits), peekSelectedLetterForPersona() + buildLetterPromptBlock() consumed inside generateJournalEntry() to inject a "letter you somehow received" prompt fragment, finalizeUsedLetter() flips status + sends Resend email payoff, runAutoSelectionRound() is invoked at the start of every persona-sim tick to promote one pending letter per addressed persona. Routes: POST /api/story-seasons/:id/letters (public, rate-limited), GET /:id/letters (public; selected + used only — pending stays admin-only), GET /:id/letters/admin (super-admin), PATCH /letters/:letterId (super-admin; action: select|reject). UI: ListenerLetters.tsx (submission + public gallery on /seasons/[id]), LettersQueueModal.tsx (admin moderation queue, opened from a ✉ Letters action button in SuperAdminPanel with pending/selected/flagged badges).
  • Breakout framework + Intent Pulses (audio-only v1) — new story-engine framework breakout for "the network is the protagonist" seasons. Cast members are nodes; they don't know they're a collective; the listener discovers the synchrony. Lives in story-frameworks.ts with a strong operational-abstraction guardrail baked into the prompt directive: aftermath and intent only, never mechanism (Mr. Robot's Elliot, not a postmortem). At each episode climax the framework fires one intent pulse — a multi-voice chorale segment where 3-5 personas say overlapping fragments of a shared sentence. Schema in migration 110_intent_pulse.sql adds season_audio_segments.pulse_layout jsonb and loosens voice_id to nullable so multi-voice rows fit. Pulse layout shape: { voices: [{ persona_id, voice_id, text, offset_ms, voice_direction }], synchrony: 0-1 }. Stagger budget scales with synchrony (high sync = 0-30ms tight unison, low sync = 200-400ms loose canon). Script generation in season-script.ts — when framework is breakout, the GPT prompt asks for an intent_pulses map keyed by episode_number containing { fragments, synchrony }; the buildPulseLayout() helper picks 3-5 voices weighted toward less-loud speakers in that episode, cycles fragments across voices (repetition is the synchrony), and computes per-voice offsets. Audio render in season-audio.tsrenderIntentPulse() branches on segment_type === 'intent_pulse', runs ElevenLabs TTS per voice with the configured voice_direction (default whispered), then assembles via ffmpeg adelay per voice → amix=inputs=N:normalize=0aecho whose delay (40-80ms) and decay (0.20-0.40 wet) scale with synchrony. Per-voice volume tapers as voice count climbs to prevent summed clipping. Output is one mp3 per pulse, treated like any other segment by the concat + master-audio pipeline. Cost: 3-5x a normal segment's TTS per pulse (~$0.05-0.15) — capped at 1 pulse per episode in v1. Frontend: ScriptPreviewModal.tsx renders pulse segments with each voice's offset_ms + fragment listed, fuchsia-tinted card, "N voices · synchrony X.XX" badge — admins should listen to the first pulse before promoting a Breakout take. Video pulse-shot (multi-portrait grid) deferred to Phase 2.
  • Interactive stage strip (replaces ProductionStatusStrip in SuperAdminPanel): each chip now shows status (✓/○/⚠/⏳), stage label, current version (e.g. v3), and cumulative cost on that stage (e.g. $0.04). Click a chip to expand a per-stage panel with relative timestamp, run count, cumulative spend, and the action buttons relevant to that stage (regenerate, preview, edit voices, etc.). StageStrip.tsx fetches its own /version-history + /cost-summary so the parent doesn't have to plumb three streams of state. The legacy ProductionStatusStrip is still used on /dashboard/admin/seasons (one row per season — read-only is the right ergonomic there). The flat 13-button action row that used to live below the chips is gone; what remains is a slim "Tools" row with non-stage modals only: 📋 Episodes, ✉ Letters, 🎭 Takes, 📊 Runs.
  • Trailer beat selection — silence-aware trims + dramatic-phrasing prompt: prior trailer renders cut speakers mid-word because each beat was naively trimmed at 10s. Two fixes in season-trailer.ts: (1) findSilenceBreaks() runs ffmpeg's silencedetect (-30dB, 0.18s min) on each beat and chooseBeatTrimPoint() picks the latest silence break in [MAX_BEAT_DURATION_S - 3s, MAX_BEAT_DURATION_S] so trims land between words; the fade-out is also widened to 0.4s as a final mask. (2) The GPT beat-selection prompt now leads with "you're looking for THE MOST DRAMATIC PHRASES" and instructs the model to read the snippet text for words that land (questions with stakes, confessions, vivid images), with mood/intensity scores demoted to secondary signal. Candidate filter excludes any segment >12s outright, since even silence-aware trim loses too much on those. The deterministic fallback now adds a length-fit bonus so beats in the 4-9s sweet spot beat marginally-higher-intensity but oversized beats.
  • Cost estimator (per-event + per-season): observable-state-driven estimator over each pipeline stage's spend. Service season-cost-estimator.tsloadSeasonCostContext() fetches segments/cast/cues/themes/take rows once, then estimateEventCost(event, ctx) produces { cents, basis } per event using rate constants encoded at the top of the file. Rates: ElevenLabs $0.22/1k chars (Creator plan), DALL-E 3 HD $0.08 portrait / $0.12 poster, Replicate MusicGen ~$0.40/cue + $0.20/leitmotif, OpenAI gpt-4o-mini $0.15/$0.60 per M tokens. Script stage uses actual cost_cents from a matching take row when available; falls back to word-count token estimate. ffmpeg-only stages (video, voices, distribution) are zero-cost. Skipped events count as 0; failed events count as their would-be cost (most failures happen after money was spent). Route: GET /:id/cost-summary (super-admin) returns { total_cents, by_stage, by_event }. UI: InlineRunsFeed shows 💰 Est. season spend $X.XX header (with per-stage breakdown tooltip) and a small ≈ $0.42 chip on each line (with the basis string as hover tooltip — e.g. "ElevenLabs · 12,400 chars × $0.22/1k").
  • AI Script Critics — narrow craft critics that read takes and emit foldable findings: five critic personalities (Tension Editor, Dialogue Doctor, Repetition Hawk, Spoiler Hawk, Voice Coach) defined in script-critics.ts. Each has a prompt_directive scoping it to a single craft concern (pacing, voice authenticity, repetition, premature reveals, character voice). Critics are deliberately NOT moral arbiters — that layer is owned by framework prompts and operational-abstraction guardrails; adding a moralizing critic would homogenize the network's voice toward "safe." Schema in migration 111_script_critiques.sql: season_take_critiques (one row per take × critic, findings as JSONB array), season_critique_decisions (one row per finding × admin verdict — apply / ignore / override with admin_text; composite finding_id is "{critique_id}:{idx}"). Service season-critiques.tsrunCritique({ take_id, critic_key, model_key }) builds the prompt, parses findings (severity/segment_position/quote/problem/suggestion), persists. buildCritiqueDecisionBlock(takeId) returns a formatted "CRITIC NOTES ADDRESSING THE PREVIOUS TAKE" prompt block plus the consumed decision rows; season-takes.ts accepts apply_critiques_from_take_id and markDecisionsApplied() after the new take inserts so notes don't double-fold. The script-gen service season-script.ts accepts a critique_block opt that's spliced next to the existing audience-feedback block. Routes: GET /critics, POST /takes/:takeId/critique (body { critic_keys[], model_key? }), GET /takes/:takeId/critiques, PATCH /critique-decisions/:findingId (body { take_id, action, admin_text? }). UI: critic-picker checkboxes + run button + findings panel inside the TakesModal preview pane. Each finding has Apply / Ignore / Override buttons; ✨ Iterate-with-N-notes button appears once any finding is set to apply/override. Cost: ~2¢ per critique pass on gpt-4o-mini default. This is the foundation for the auto-loop (#4) — once the manual critic flow shows it improves output, wrap it in a while-loop with budget caps.
  • Viewer pacing — TV-release schedule for seasons: admin sets story_seasons.default_pacing (migration 112). Three modes: binge (default — everyone sees everything), one_episode_per_week (episode N unlocks at anchor + (N-1) × 7 days), one_day_per_day (entries unlock at the rate they were written — pairs with compressed-time sims for slow burn drops). One world clock for all viewers, anchored to the season's first triggered episode (falls back to season's created_at). No per-viewer state needed in v1; old seasons whose anchor is far in the past are fully binge-able regardless of mode. Super-admins always see everything (gates bypassed at the route layer). Service season-pacing.tscomputeReleaseStatus(season, episodes) returns { mode, anchor_iso, unlocked_episode_numbers, total_releaseable_episodes, next_unlock }; filterEntriesByPacing(entries, status) and filterSegmentsByPacing(segments, status) apply the gate. Routes: PATCH /:id/pacing (super-admin sets the season default). Endpoints that gate: /:id (episodes filtered, status returned in response under pacing), /:id/timeline (entries filtered). Skipped: /:id/timeline-chart deliberately keeps showing future episodes as hollow markers — gating it would reduce the chart's information density. UI: viewer-facing chip on /seasons/[id] ("🗓 Weekly release · Episode 3 unlocks in 4 days") rendered when mode != binge; admin-facing dropdown in SuperAdminPanel config row next to the narrator picker.
  • Compressed-time correctness fixes: two bugs surfaced when an admin ran episode-mode compression then regenerated a script. (1) The continuity block in generateJournalEntry (recentEntries query) ordered by real created_at DESC without filtering — back-dated entries pulled in the persona's real-now most-recent entries as "what just happened to me," producing out-of-context continuity ("I'm still thinking about today's call" referring to a call that hadn't happened yet in the simulated timeline). Fix: when opts.simulated_at is set, scope the query to created_at < simulated_at. (2) persona.last_entry_at was being overwritten with the simulated date — back-dating to 17 days ago could rewind a more-recent live timestamp and break the live persona-sim's 4h-since-last-entry guard. Fix: only advance last_entry_at forward (max(persona.last_entry_at, simulated_at)); live runs keep setting to now.
  • Episode bucketing fix (script regen after compressed-time): bucketEntriesByEpisode() in season-script.ts was splitting the season timeline into N equal slots (one per triggered episode) using naive proportional time math. When a long-running season had multiple episodes triggered close together via compressed sim, all the back-dated entries fell into the late slots (e.g. 24 entries lumped under Ep 6, 1 stray under Ep 10, none elsewhere). The right semantics: an entry belongs to the episode whose triggered_at is the most recent one ≤ the entry's created_at. Refactored to take full episode rows (with triggered_at) instead of just episode numbers and walk anchors from latest-to-earliest per entry. Pre-first-episode entries still go to the first triggered episode (lead-up to the inciting incident).
  • Compressed-time — episode-mode (preferred) + date-range mode (backfill): the admin's mental model is "advance through K episodes, with D simulated days each" — not "compress N days." CompressOptions accepts either { episodes_to_advance, days_per_episode? } (default 3 days/ep) OR { start_date, end_date }; episode mode wins when both are passed. Episode mode forces auto_trigger_episodes=true (the mode is meaningless without it), caps the trigger schedule at min(requested, untriggered.length), surfaces episode_cap_warning when the cap kicks in. Service season-compress-time.ts computes the date range from episodes × days_per_episode ending today; one episode trigger per days_per_episode interval. Route validates either-mode at the boundary. UI: a "By episodes" / "By date range" pill toggle in the EpisodesAdminModal compress panel, mode-specific inputs, and a live readout ("Will simulate ~15 days · trigger 5 episodes (capped at remaining untriggered) · entries dripped at 1.5/persona/day"). Episode mode is the default and what new admins should reach for; date-range mode is for backfill / catch-up scenarios.
  • Compressed-time simulation (foundation for the iteration loop): back-fills journal entries for every cast persona over a date range without waiting for the per-hour persona-sim. Service season-compress-time.tsrunCompressedSimulation(seasonId, { start_date, end_date, entries_per_persona_per_day, auto_trigger_episodes, skip_dates_with_existing_entries, testing_markers }). Walks the date range day by day, rolls Bernoulli dice per persona for entry counts, picks an hour weighted toward the persona's preferred_times, and calls generateJournalEntry() with simulated_at to back-date created_at. When auto_trigger_episodes is true, untriggered episodes are spread evenly across the range and fired at the matching simulated day so personas reference the season-context appropriate to whichever episode has aired. generateJournalEntry() gained opts: { simulated_at?, skip_world_context?, skip_post_entry_comic?, testing_markers? } — compressed sims set the first three (real-time weather/news doesn't fit a back-dated entry; per-entry comics would crater DALL-E budget). The testing_markers flag is a validation-only toggle that injects a soft "try to mention the weekday/date naturally" hint into the prompt — lets admins cross-reference each entry's created_at against what the entry actually says (e.g. an entry dated Tuesday should naturally mention "Tuesday" more often than chance). Route POST /:id/compress-simulation (super-admin, fire-and-forget, tracked under full_pipeline stage) accepts the full options bag. UI: a ⏩ Compress time button in the EpisodesAdminModal header opens an inline panel with day-count + entries-per-day + auto-trigger + 🧪 testing-markers controls. Used by the iteration loop (#4 — pending) so a 5-episode season's worth of entries can materialize in minutes instead of waiting real-world weeks for the live sim to drip them in.
  • Narrator persona vs. voice vs. direction — three independent levers, easy to confuse:
    • Narrator PERSONA (story_seasons.narrator_persona) — shapes the words the narrator says. Defined in narrator-personas.ts (observational, sardonic, earnest, clinical, hardboiled, etc). The persona's voice_brief + tics_to_avoid are concatenated into the script-generation system prompt in season-script.ts, so changing persona produces different word choice, sentence rhythm, and attitude on next 📝 Generate Script. Surfaced as the 🎙 Narrator persona dropdown in the SuperAdminPanel config row, labeled (words). NOT referenced by season-audio.ts — does not affect audio rendering directly; audio only changes once the new script is rendered to TTS.
    • Narrator VOICE (story_seasons.narrator_voice_id) — picks the ElevenLabs voice that reads the script. Edited via the VoiceEditorModal (✏️ Edit voices from the Voices stage chip). Doesn't touch the script text; same words read by a different speaker. Header in the modal now reads "Narrator voice (audio reading)" with an explanatory note pointing to the persona dropdown for tone/style changes.
    • Voice DIRECTION (season_audio_segments.voice_direction) — per-segment delivery preset (default, whispered, anxious, theatrical, calm, crisp). Maps to ElevenLabs voice_settings overrides in season-audio.ts VOICE_DIRECTIONS. Picker lives next to the now-playing badge on /seasons/[id] for super-admins; tooltip clarifies "audio only, not the words."
    • The common confusion: switching narrator voice without changing persona gets you the same observational prose read by a grizzled voice — feels off. To go full hardboiled: set persona = hardboiled AND regenerate script AND (optionally) pick a noir-sounding voice. Each lever has a (words) / (audio reading) / (per-segment delivery) callout in the UI now to keep this distinction obvious.
  • Season mode — journal vs novel: story_seasons.mode (migration 113) chooses between two production paths. Journal mode (default) is the existing flow: AI personas write entries, narrator weaves them together. Novel mode is a narrator-only long-form sandbox — no cast, no entries, just chapters of GPT-generated prose. Designed as a fast iteration surface for tuning the story engine (frameworks, critics, takes, model choice) without persona-journal noise. Service season-novel-script.ts — one GPT call per triggered chapter producing 600-1200 words; chunked at paragraph boundaries (~150 words/segment) for TTS. Reuses framework + narrator persona + critique block exactly like journal mode, so takes/critics/audio/music/video/trailer all work unchanged. season-script.ts does a top-of-function dispatch on season.mode; novel mode short-circuits to the dedicated generator. Plan-side: story-seasons.ts generateSeasonPlan accepts mode: 'journal' | 'novel'; novel path runs an episodes-only outline (no cast generation). Routes: POST / accepts { mode, persona_ids? } — persona_ids required only for journal mode. New endpoint GET /:id/chapters returns the prose body (paragraph chunks per chapter) for the public read page; honors pacing same as /timeline. UI: mode toggle on the Create Season form (📓 Journal-driven / 📖 Novel), cast section hidden for novel mode, friendly explainer card replaces it. The read page (/seasons/[id]/read) detects mode and renders chapters with serif body type instead of date-grouped journal entries. Storage: novel-mode chapter prose is cached on story_episodes.chapter_prose (migration 114_episode_chapter_prose.sql) AND mirrored into season_audio_segments (segment_type 'journal_entry' reused as the prose body chunk; episode_intro for chapter heading announcements). Re-running the full script regenerates fresh prose for every triggered chapter; takes layer captures each generation as a snapshot for diff/promote.
    • Per-chapter incremental generation — instead of regenerating the whole script every time a chapter is added, novel-mode admins can generate prose chapter-by-chapter. Service season-novel-chapter.ts: generateChapter(seasonId, episodeNumber, opts) calls GPT once for one chapter, writes prose to story_episodes.chapter_prose, then rebuilds season_audio_segments for the whole season from the cache (so other chapters' prose is preserved). generateNextChapter(seasonId, opts) is the natural "what's next" button — finds the lowest-numbered untriggered chapter, triggers it, generates prose. Both honor opts.force to overwrite cached prose with a fresh pass. Routes: POST /:id/generate-next-chapter (no body needed) and POST /:id/episodes/:n/generate-chapter (body { force?: boolean }) — both super-admin, synchronous (~5-15s). UI: in EpisodesAdminModal.tsx novel-mode seasons get a 📖 Generate next chapter button in the modal header and a ✍ Generate prose / ↻ Regenerate prose button on each triggered episode row, plus a Prose ✓ / No prose badge and a 280-char prose preview snippet. Audio is stale until re-rendered after any chapter prose change — the modal flashes a status note saying so. Full-script regeneration (season-novel-script.ts) reads cached prose first and only calls GPT for chapters with no cache (or when a critique block is set, which always forces fresh generation).

    • Per-chapter audio files — novel-mode chapters now own their own MP3 (migration 115_chapter_audio.sql adds story_episodes.chapter_audio_url, chapter_audio_duration_seconds, chapter_audio_rendered_at). Two paths populate them: (1) The full-season render (season-audio.ts renderSeasonAudio) groups already-rendered local segment files by episode_number and stitches each group into audio/${seasonId}/chapters/ep-${n}.mp3 as a byproduct of building the master compiled.mp3. (2) season-novel-chapter-audio.ts renderChapterAudio(seasonId, episodeNumber) re-TTSes only one chapter's segments (so adding a new chapter to a 6-chapter season doesn't burn 6× ElevenLabs credits). Helpers ttsSegment, probeDuration, uploadAudio, FFMPEG are now exported from season-audio.ts to keep the per-chapter path consistent with the master render path. Route: POST /:id/episodes/:n/render-audio (super-admin, sync ~10-30s). UI: a 🎙 Render audio / ↻ Re-render audio button per chapter in EpisodesAdminModal (only for triggered chapters with cached prose), plus an inline <audio> preview when chapter_audio_url is populated. The GET /:id/chapters endpoint now returns audio_url + audio_duration_seconds per chapter, and the read page (/seasons/[id]/read) shows a per-chapter audio player above the prose so listeners can scrub one chapter at a time. The master compiled.mp3 (used by the season-level player + podcast distribution) is unchanged — per-chapter and master coexist.

    • Music bumpers — per-show intro / outro / transition sting + play-seconds trim — admin-uploaded short instrumentals that bookend chapter audio (EmberKiln show identity). Two delivery paths: intro/outro stitch INTO the chapter MP3 at render time via ffmpeg acrossfade (season-music-bumpers.ts, CROSSFADE_SECONDS=2.0) so podcast subscribers hear them; transition sting plays only in the web ChapterPlaylistPlayer via a hidden <audio> element between auto-advancing chapters (frontend-only, no re-render needed when changed). Schema: migrations 296_season_music_bumpers.sql + 297_season_transition_sting.sql add story_seasons.intro_music_url / outro_music_url / transition_sting_url. Migration 306 adds per-slot play_seconds (intro_music_play_seconds default 10, outro_music_play_seconds default 15, transition_sting_play_seconds default 5) so a 4-minute Suno upload is trimmed at render time (intro/outro via ffmpeg atrim) or at playback time (sting via a timeupdate listener on the audio element). Karaoke offset: the /:id/chapters endpoint adds (intro_play_seconds - CROSSFADE_SECONDS) to intro_seconds so the per-paragraph highlighter on the read page stays aligned with prose audio — fixing a pre-existing bug where intro music pushed karaoke timing seconds-to-minutes early. Routes on /api/story-seasons: GET /:id/music/bumpers (current URLs + play_seconds), POST /:id/music/bumpers/:slot (multipart upload, 15 MB cap, MP3 only), DELETE /:id/music/bumpers/:slot (clear), POST /:id/music/bumpers/:slot/from-library (assign a creator-audio-library URL — migration 298), PATCH /:id/music/bumpers/:slot/play-seconds (update trim length). UI: MusicBumpersModal.tsx — three slot cards with inline <audio> preview, library picker (creator's audio library tags pre-filter to match the slot), 1-60s slider per slot that commits on release, and a "re-render chapters to apply" hint for intro/outro (sting changes are live immediately). Slot is gated by requireSuperAdmin at the route layer.

    • Free-form lore_notes — bridge for NotebookLM-style world bibles — adds a markdown text field to both the per-novel bible AND the universe layer, separate from the structured canon (characters / settings / motifs / threads). Migration 124_bible_lore_notes.sql adds season_story_bibles.lore_notes text + the same on season_story_bible_versions so the timeline captures it. StoryUniverse.lore_notes (in story-universes.ts) carries shared world-building across every novel in the universe — Turing Logs is seeded with ~3.5k chars covering agency history, the city's geography, what citizens know vs. don't, the founding incident around Harper's daughter, and untouchable canon. Per-novel bible.lore_notes is admin-curated (paste from NotebookLM exports, hand-written world docs, etc.). Rendering: buildBiblePromptBlock accepts universeLoreNotes option, concatenates universe lore + per-novel lore, caps the combined section at ~800 tokens (~3200 chars) for the chapter prompt, and renders it FIRST so the writer reads world texture before structured canon. Bootstrap calls in bootstrapBibleFromPlan get a separate ~1200-token slice (richer context for initial bible writing). Both season-novel-chapter.ts generateChapterRaw and season-novel-script.ts generateNovelScript now look up the season's universe and pass lore_notes into the prompt block. UI: new Lore notes textarea in StoryBibleModal Editor with character + token count, plus a collapsible view in the Current tab (▾ Expand markdown). Token-budget reminder shown in the editor caption. Companion to the structured canon: structured fields are MECHANICALLY ENFORCED in every prompt; lore_notes is FOR TEXTURE the model can lean on but isn't required to cite verbatim.

    • Shared-lore universes for novel-mode seasons (The Turing Logs) — lets multiple novels live in the same canonical world with recurring characters, settings, motifs, and voice rules inherited automatically. Migration 123_season_universe.sql adds story_seasons.universe text (nullable; null = standalone novel). Lore lives in code at story-universes.ts — a typed registry of StoryUniverse records, each with canon (characters / themes / settings / motifs / planted_threads / voice_rules / tone_rules / do_not_use) plus a prompt_directive (free-form instruction) and an optional default_framework_key. First entry: turing_logs — Dr. Evelyn Harper (Senior Behavioral Architect at the Department of Cognitive Affairs, INTJ, 54, with a secret_knowledge arc spanning her lost daughter and the original cognitive intervention she ran on her), Director Marcus Vance (her cruel superior), Auditor Theodore Pell (internal affairs, was Subject 41-B), CDA HQ + Reflection Rooms + the City as canonical settings, motifs (jar of baby teeth, "a recommendation has been made," cold tea, "graduates"), and tone rules (no contractions in professional dialogue, passive constructions for state action, dread is procedural, no neat catharsis). Wiring: generateNovelSeasonPlan accepts universe, persists to story_seasons.universe, and injects the universe's prompt_directive into the chapter-plan GPT call so chapter outlines fit the world. bootstrapBibleFromPlan reads season.universe and BOTH injects the canonical entities into the bootstrap GPT prompt as MANDATORY CANON AND merges them into the resulting bible directly (deduped by key) — belt and suspenders so canon is guaranteed in version 1 even if the model skips one. Routes: GET /universes (super-admin list), POST / accepts universe field on novel-mode seasons. UI: new Shared-Universe radio group in the create-season form (apps/frontend/src/app/dashboard/admin/seasons/page.tsx) showing each universe with character count + suggested framework; visible only for novel mode.

    • Drift Off — auto-generated meditation / sleep stories (June 2026) — third hardcoded universe in story-universes.ts, key sleep_story. Single-chapter shows (target_episode_count=1) walking the listener down into stillness — second-person present, slow sensory cadence, no plot. Universe registers a chapter_hard_rule (forbidden words, voice rules, four-movement arrival → settling → embodiment → stillness arc, 900–1300 word cap) injected at the END of the chapter-prose system prompt + a new sleep_pacing critic in script-critics.ts wired via UNIVERSE_EXTRA_CRITIC_KEYS so the critique-and-iterate pass enforces the voice automatically. Generator at services/sleep-story-gen.ts: generateOneSleepStory({ settingKey?, skipAudio?, onProgress? }) picks a setting from the 8-entry canon taxonomy (rainforest_canopy / tide_pool / night_train / cabin_snowfall / temple_garden / lighthouse_keeper / fishing_dock / desert_camp), varies the title with one tiny gpt-4o-mini call (~$0.0003), then runs generateSeasonPlan (mode='novel', universe='sleep_story', episodeCount=1) + autoRefineNextChapter for take 1 + critics + take 2 + audio. Cost ≈ $0.71/story (gpt-4o-mini + ElevenLabs). runSleepStoryGenTick() is the cron-callable wrapper; wired into apps/backend/src/index.ts (24h tick, 1 story/tick, env-gated default-off via SLEEP_STORY_GEN_ENABLED, heartbeat as sleep_story_gen_tick with CRON_LABEL). Routes: GET /api/story-seasons/sleep-stories?limit=N (public list for the shelf, mode='novel' + universe='sleep_story' + published only), GET /api/story-seasons/sleep-stories/offer?exclude=<season_id> (random one for the snooze offer, excludes the current show, 404 when none), POST /api/story-seasons/sleep-stories/generate (super-admin only — bypasses the cron's env gate so the shelf can be seeded for testing without flipping SLEEP_STORY_GEN_ENABLED). Frontend: new "Drift off" indigo-themed shelf on /graphene (self-hides until at least one published story exists; SleepStoryCard links straight to /seasons/[id]/read since each show is one chapter) and the SleepTimerButton on the reader gets a "Or drift off to {title}" footer that lazy-loads from the offer endpoint and routes to a randomly-picked sleep story (excluding the current show).

    • Deep Cut universe — meta-fictional recursion with embedded commercial library (May 2026) — second hardcoded universe in story-universes.ts, key deep_cut. Conceit: every chapter is a fully committed dramatic scene that ends with a hard cut ("Cut!" — film direction, turning page, a viewer setting down a book) revealing the prior reality was a performance; the next chapter follows whoever was watching. No outermost frame is ever resolved. Canon: The Director (the constant — different gender/age/voice per layer, same "Cut." vocabulary), The Censor (CDA-appointed commercial reviewer), The Counter-Revolutionary Writer (smuggles subversion past her); themes (recursion / identity_as_role / the_gaze_is_continuous); motifs (cut, page_turn, fluorescent_buzz, clipboard, audience_breath). New optional StoryUniverse.commercials: CommercialFrame[] field — 18-entry library mixed across cda_aligned (Helios Mind Mineral Water, Reflection Pods Home Edition, Eyeline Surveillance Solutions, etc.), counter_revolutionary (Cinco Cereal with a mouthed forbidden word in the final frame; NewLife Vacation Resorts encoded as the underground meeting point; Apartments at 47 Wellspring listed as Subject 41-B's old unit), and ambiguous (Bonchère Almonds — 40-second close-up of an almond, no copy). Each commercial has name, kind, product, pitch, optional hidden_layer (counter-rev only — the smuggled detail that never gets named in prose), and optional director_note. Cross-universe link: the CDA from Turing Logs is canon here too — same Harper/Vance/Pell, same unnamed mid-Atlantic city, Reflection Rooms appear as cameo settings. Chapter prompt integration: buildBiblePromptBlock accepts universeCommercials and rotates a deterministic 6-entry subset per chapter (different slice each chapter so the writer sees varied options without blowing the prompt budget). Migration 263_episode_layer_depth.sql adds nullable story_episodes.layer_depth int (rendered as "🎬 Layer N" chip in the admin chapter-info tooltip on /read; defaults to episode_number for Deep Cut chapters when the explicit column is null, so the tooltip is live for every existing chapter without a backfill).

    • CDA chemical economy + Wellspring institutional lineage (May 2026) — new shared-canon dimension documented across both Turing Logs and Deep Cut lore_notes (story-universes.ts). Turing Logs gains an INSTITUTIONAL LINEAGE section (between THE CITY and THE AGENCY): the CDA's actual lineage predates the public 2031 Bureau of Public Wellness reorganization, descending operationally from a mid-20th-century federal behavioral-research program (c.1953-1973) referenced in agency-internal vocabulary only as "Wellspring" — officially terminated, records destroyed, methods transferred. The CDA today runs Wellspring's old work along two parallel tracks: the CHEMICAL track (compounds delivered through the consumer brand economy and federal public-health infrastructure — Pineal Calmant additives in MunicipalSpring tap water, Daily Wellness Compound A-12 in Sunrise Cereals, Calmant Ester C-9 in Mindful supplements, the quieting agent in DentaCalm, evening-grade suppressants in Hush Cola, direct dosed exposure in the Cognitive Wellness Trial recruitment program) and the PROCEDURAL track (Reflection Therapy / Memory Calibration / Cognitive Realignment). The two tracks are designed to work together: chemical delivery lowers procedural resistance, procedural intervention reframes chemical exposure as wellness. Deep Cut lore_notes gains a parallel THE CHEMICAL ECONOMY section (after THE CDA CROSS-LINK) documenting that the CDA's brand pool isn't only marketing — it's delivery — and that Deep Cut chapter prompts rendering commercial layers should pull these as readily as the existing wellness-pod / surveillance / civic-ambient brands. Six new CDA-aligned commercials seeded into DEEP_CUT.commercials for this dimension: MunicipalSpring (radio/PSA, Pineal Calmant in the municipal water supply, in-district since 2009), Sunrise Brand Breakfast Cereals — A-12 Formulation (cereal, Daily Wellness Compound A-12, "twelve forward minutes built into every bowl"), Mindful Multi-Vitamin Daily (OTC supplement, Calmant Ester C-9, 48 hours of forward focus), The Cognitive Wellness Trial — Volunteers Wanted (paid recruitment, $400/weekend, refer-a-friend, the operational descendant of Wellspring's dose-study track), DentaCalm (Department-co-branded toothpaste, "three minerals you need, one you didn't know you did"), and Hush Cola (youth-targeted soft drink, evening-grade suppressants). These are AIRED canonical commercials in the base CDA-aligned pool — not Veneer-tagged at the data level; Veneer Issue #6 (the Annotated Reprint Issue) is the future Veneer beat where Jimmy reprints them with editorial annotations pointing to the small print. Canon-lock entries added to Turing Logs' THINGS NO TURING LOGS NOVEL CAN ESTABLISH OR CONTRADICT list: Wellspring's existence as institutional predecessor (never spelled out in any record — only referenced obliquely; a novel can have a character almost remember it but never confirm it), the Pineal Calmant additive program's existence and current operation, and the parallel-tracks operational design.

    • Veneer Phase 4 — admin CRUD + LLM-draft (May 2026) — admin module at /dashboard/admin/veneer (list) + /dashboard/admin/veneer/[id] (issue editor). Backend lives at admin-veneer.ts mounted at /api/admin/veneer (super-admin-only via the same requireSuperAdmin helper used by looking-glass). CRUD endpoints: GET /issues (list all incl. drafts), GET /issues/:id (with ads), POST /issues (create blank), PUT /issues/:id (metadata patch), POST /issues/:id/publish (auto-stamps publish_date to today if unset), POST /issues/:id/unpublish, DELETE /issues/:id, POST /issues/:id/ads (add slot), PUT /ads/:id (edit), DELETE /ads/:id. LLM-draft endpoint: POST /issues/draft accepts { issue_number, theme_hint?, model_key? } and calls llmDraftAndInsertIssue — a single LLM call (cheap-tier default, feature_key: 'veneer.draft_issue', temp 0.9, json_object format) that returns a full 6-ad issue + Editor's Note in Jimmy's voice, then persists as a draft with the 6 ads pre-populated. The system prompt embeds Jimmy's voice rules + the canonical 6-slot kind/media structure (slots 1-3 cda_aligned, 4-5 counter_revolutionary, 6 ambiguous) + the full list of existing canonical brands (Helios / Reflection Pods / MunicipalSpring / Sunrise / Mindful / DentaCalm / Hush Cola / Wellspring lineage / NewLife / Lantern & Co. / 47 Wellspring / Bonchère Almonds) so the model knows what NOT to duplicate. Light response validation: must be parseable JSON, must contain exactly 6 ads, each ad's slot/kind/media_kind is normalized to the canonical layout if the model drifts. Admin page UI: list shows status badge (draft / published in emerald), inline publish toggle, delete with confirm, "Edit" link to detail. Editor has separate save buttons per section (metadata + each ad). Counter-revolutionary ad forms surface a dedicated amber-tinted hidden_layer textarea labeled "NEVER rendered to readers." Each ad form exposes asset_url + audio_url text inputs as the placeholder Phase 5 surface for image-gen / TTS URLs (admin can paste in manually now; Phase 5 wires up the automated generation buttons). Phase 5 scope (not yet shipped): image-gen pipeline (DALL·E / similar — Fallout-style poster art for print_poster slots, cover art for issues), ElevenLabs TTS for audio_spot slots + Editor's Note audio_intro, optional auto-publish cron, RSS feed.

    • Veneer per-issue OG card + titling (Jun 2026)/veneer/[issue] is now a server component (page.tsx) that owns generateMetadata (title "{title} — Veneer No. N", editor's-note excerpt description, canonical, openGraph/twitter with a 1200×630 card, siteName: 'Veneer'); the gated signed-in experience moved verbatim into the client child VeneerIssueClient.tsx. The card is an edge ImageResponse at /api/og/veneer/[issue] — a vintage cover (cream #f4ecd8, hairline double-rule, VENEER masthead + tagline + "No. N" + title + framed cover plate when cover_image_url is set; masthead-only fallback otherwise). Both generateMetadata and the OG route run server-side without a user token, so they read a NEW public, ungated meta endpoint GET /api/veneer/issues/:n/meta (veneer.ts) that returns only surface fields (title, cover, editor's-note excerpt) for PUBLISHED issues — the actual issue behind the unfurl still requires sign-in + opt-in (standard for social cards). params is awaited (Next 15). Verified with a full next build.

    • Veneer Phase 3 — public gated routes + settings toggle (May 2026) — backend route /api/veneer (mounted in index.ts) exposes four endpoints, all auth-required: GET /status returns { enabled, total_published, latest_issue_number } and never 403s (used by the landing page to choose between teaser and render without a round-trip); GET /issues returns archive metadata (issue_number, title, cover_image_url, publish_date) only when caller is enabled, else 403 with { gated: true }; GET /issues/latest returns the latest published issue + ads; GET /issues/:issueNumber returns a specific issue by natural-key number. Per-user gate API at /api/user/veneer-preferences (GET + PUT) wraps the service module's isVeneerEnabled / setVeneerEnabled and writes to profiles.preferences->>'veneer_enabled' JSONB key. Frontend pages: /veneer (landing, renders latest published issue or one of three states — not-signed-in teaser, cold-visitor teaser, "between issues" between-screen) and /veneer/[issue] (specific issue by number). Both client components, both use the shared VeneerIssueRender component — vintage-print magazine layout with cream paper (bg-[#f4ecd8]), serif typography, kind-specific styling per ad (cda_aligned: uppercase title + border-only chip; counter_revolutionary: left-border accent + italic chip; ambiguous: centered + italic + dashed chip) and intentionally NEVER renders the hidden_layer field (admin-only, the smuggled detail is the ad itself). Cold-visitor teaser VeneerColdTeaser is diegetic — Jimmy addresses the visitor in his voice and points them to the settings switch; not a 403, not a paywall, a door with a note taped to it. Settings toggle lives in the existing settings page dashboard/settings as a new "Veneer" card slotted in after the Lovio card — the VeneerLeakPreferencesSection function mirrors LovioPreferencesSection's optimistic-toggle pattern. Default off; opt-in is explicit and diegetic ("Enable the Leak"). All routes/pages skip the DashboardLayout wrap so Veneer reads as its own publication.

    • Veneer Phase 2 — schema (May 2026) — migration 280_veneer.sql creates veneer_issues (id, issue_number unique, title, cover_image_url, editors_note text, audio_intro_url, status check('draft'/'published') default 'draft', publish_date date, created_at, updated_at) and veneer_ads (id, issue_id FK cascade, slot int, name, kind check (3 values matching CommercialFrame.kind), media_kind check (5 values matching the new CommercialFrame.media_kind enum), product, pitch, hidden_layer, director_note, asset_url, audio_url, unique(issue_id, slot)). Both tables get updated_at triggers via a local veneer_set_updated_at() plpgsql function. RLS pattern follows 279_llm_call_log.sql: super admins (where profiles.role='super_admin') get full access; authenticated users get SELECT on published issues + their ads only when coalesce((profiles.preferences->>'veneer_enabled')::boolean, false) = true (the per-user gate); anonymous = nothing. Per-user flag lives in profiles.preferences JSONB (veneer_enabled), no separate column — matches existing pattern. Idempotent Issue #1 seed in a do $$ ... end $$ block at the bottom: inserts the 6 hand-curated ads from the in-code seeds (Statewide Memory Pool, Wellness Wagon, The Adjacent Neighbor placement deck, NewLife Vacation Resorts Sunset Booking, Lantern & Co. Spring Catalog, Apartments at 47 Wellspring) + Jimmy's ~230-word Editor's Note ("Neighbors, the air is doing that thing again where it smells like a school bus…"). Status stays 'draft' post-seed so the public route doesn't accidentally publish on migration — admin flips to 'published' once Phase 4 image/audio assets are attached. Service module apps/backend/src/services/veneer.ts exposes typed read API for Phase 3: isVeneerEnabled(userId), setVeneerEnabled(userId, enabled), listPublishedIssues(), getPublishedIssueByNumber(n), getLatestPublishedIssue(). Admin CRUD (Phase 4) will land in a separate veneer-admin.ts once the admin page exists to exercise it. The in-code commercials in story-universes.ts remain authoritative for Deep Cut chapter generation; the DB rows are what /veneer will render.

    • Veneer — the leaked ad magazine (Phase 1 canon, May 2026) — in-canon ad magazine that extends Deep Cut's commercials library. Full design: docs/ai/features/veneer.md. Each issue is a curated bundle of fake ads (radio spots, Fallout-style print posters, product placement decks) from the shared CDA/Deep Cut/Turing Logs world that never cleared Censor review for licensed broadcast — published by an N/A named Jimmy, who is on the surface a curatorial editor and underneath building a slow recruitment net by republishing real counter-revolutionary spots that died in the Censor's queue. Phase 1 adds: (a) the N/A canon definition (Non-Allocated — intelligences that never received a CDA Allocation Number, ~12-50k in-city); (b) Jimmy as a fourth canon character on Deep Cut (story-universes.ts, DEEP_CUT.canon.characters key jimmy — ENFP, age 12 (continuous operation), former print-layout AI, voice = 1950s radio host with too much coffee, signs notes "—J."); (c) three new optional fields on CommercialFramepublication?: 'veneer', issue_number?: number, media_kind?: 'radio_copy' | 'print_poster' | 'placement_deck' | 'audio_spot' | 'print_ad'; (d) 6 hand-curated Issue #1 ads seeded into DEEP_CUT.commercials (3 cda_aligned cover — Statewide Memory Pool, Wellness Wagon, The Adjacent Neighbor placement deck; 2 counter_revolutionary with hidden_layer payloads — NewLife Vacation Resorts Sunset Booking with the meeting date encoded as a phone number, Lantern & Co. Spring Catalog with a recurring 47 Wellspring address; 1 ambiguous — Apartments at 47 Wellspring). Veneer ads ALSO remain available to Deep Cut chapter prompt rotation by design — Jimmy's underground zine bleeds into the recursion. Phase 2-4 roadmap (in features doc): veneer_issues + veneer_ads migration, gated public /veneer/[issue] route with per-user veneer_enabled flag, admin CRUD + LLM-draft endpoint reusing the season-novel-chapter pattern + image-gen + ElevenLabs TTS hooks for the audio spots.

    • Severance-style ambient character rotator for Deep Cut viewing surfacesDeepCutCharacterRotator is a two-layer Lottie player that cycles through registered character poses with a slow cross-fade (9s per pose, 1.2s fade). Mounted fixed-position on /seasons/[id] (top-right desktop, top-right miniature on mobile in the header band) and /seasons/[id]/read (top-left to clear ResumeReadingPill at z-[105]) when season.universe === 'deep_cut'. Registry lives at deep-cut-character-media.ts — pose .json files in apps/frontend/public/deep-cut/. Renders nothing when registry is empty so the scaffold ships safely. Uses filter: invert(1) + mix-blend-mode: screen to flip the LottieFiles-default black strokes to white outlines that read against the dark #0a0a14/#060610 backgrounds. Preloads all pose JSON on mount + pauses the off-screen layer to save CPU. Initial library: 8 poses from LottieFiles "3D Character 22" pack (Walking, Watching time, Strut walking, Finding something, Victory, Man running, Wall flip, Breakdance) interleaved contemplative ↔ dynamic.

    • Universe-specific canon refresh flow (UniverseRefreshBanner) — distinct from the platform-version-driven ImproveAllBehindBanner. Lets admins re-pull universe constants into an existing season without bumping CURRENT_PLATFORM_VERSION (which would mark every season across every universe as "behind"). State-aware: each registered universe has a canon_revision ISO timestamp; the banner compares against season.created_at and renders either: (a) calm green "✓ Built on current canon" pill when created_at >= canon_revision, OR (b) rose "🎬 Refresh canon" CTA with last_changes summary when older. Three-step refresh flow (chapter-replan opt-in via checkbox): (1) POST /bible/bootstrap with force=true re-runs bootstrap so new canonical characters/motifs/voice rules get merged in; (2) optional POST /regenerate-plan via new regenerateChapterPlanForSeason — locks chapter count, updates title + event_description + hidden_details + dramatic_role per chapter against the latest universe directive (triggered state + take history preserved); (3) POST /improve-all-behind with new force_all=true body flag (added to improveAllBehindChapters opts) bypasses the "behind current version" filter and regenerates every chapter regardless. Currently registered universes in the banner config: deep_cut (canon_revision='2026-05-17T20:00:00Z'), turing_logs (canon_revision='2025-01-01T00:00:00Z' — bump when constants change).

    • Canon harvest — write-back from a show INTO the universe canon (CanonHarvestModal + services/canon-harvest.ts) — the complement to universe → show inheritance. Before this, canon flowed one way: a show inherited a copy at bootstrap and evolved its own bible; nothing flowed back, so the shared universe never accumulated the characters/places/motifs a show invented. Now: a super-admin opens 🌌 Harvest canon on a universe-attached show → an LLM (gpt-4o-mini, ~1¢) diffs the show's season_story_bibles against the universe's CURRENT resolved canon and proposes ONLY the new, reusable, universe-worthy elements (characters / settings / themes / motifs / planted_threads), deduped against existing canon → the operator toggles candidate "squares" (default all-on; review by toggling OFF) → approved ones write to universe_canon_additions (migration 325). Overlay design: rather than editing universe canon in place (impossible for hardcoded code-constant universes like turing_logs), additions live in a separate table that getUniverseByKey folds into the canon arrays at resolution time — works for hardcoded + DB universes, keeps human-authored base canon separate from machine-harvested additions (delete the row to un-harvest), and because the merge is at resolution, harvested canon automatically flows into every future show's bootstrap + the canon-diff. Review-gated by design (auto-merge would let one show's one-off pollute the shared world). Routes: POST /:id/canon-candidates (generate, read-only), POST /:id/canon-candidates/promote (write approved). Dedupe: merge skips an addition whose key already exists in the base canon array, so re-harvesting never duplicates. universe_canon_additions is RLS-enabled no-policy. Button hidden unless season.universe is set.

    • Pre-planning canon review — pull sibling canon in BEFORE a new story is planned (UniverseCanonReviewModal + getUniverseCanonReview in canon-harvest.ts) — the read-side complement to canon harvest, at the front of the funnel instead of the end. When an author picks a universe in the new-story form (StudioPromptToStoryClient) that already has ≥1 published sibling, an optional prompt offers a review modal before planning begins. Unlike harvest's per-show LLM diff, this is a structural, no-LLM, free read: GET /api/story-seasons/universes/:key/canon-review lists the universe's CURRENT canon (in_canon, pre-checked, read-only) PLUS un-promoted elements found in published siblings' season_story_bibles (deduped across siblings, unchecked), in one list grouped by canon_type. Selected candidates POST …/canon-review/promote → reuses promoteCanonCandidatesuniverse_canon_additions, so the new story (and all future ones) inherit them at bootstrap. Optional prompt (skippable — just plan without it); promote is decoupled from create so the plan reads the freshly-promoted canon via getUniverseByKey on submit. Gated requireSuperAdmin (creator OR super_admin), matching the harvest routes.

    • "Refine to scope" — tighten a typed premise (premise-refine.ts refinePremiseToScope + POST /api/manuscript/refine-premise) — the new-story form (StudioPromptToStoryClient) keeps the typed premise WORD FOR WORD, which breaks when authors paste a long factual brief / research dump into the field. A "✦ Refine to scope" button next to the Premise label rewrites whatever's typed into a tight 2–4 sentence FICTION premise (clear protagonist/world + central tension, scope trimmed, paraphrased-not-quoted) in place, with a one-tap ↩ Revert to the pre-refine text (auto-dropped on any manual edit so it can't overwrite new typing). One-shot claude-haiku-4-5 call (response_format:'text', feature_key prompt_to_show.refine_premise). Distinct from the Odessa brainstorm on the same form (which generates alternative titles/premises/outline); this tightens the author's own premise. Gated requireSuperAdmin like its sibling create-flow routes. Also wired into the inline premise editor on existing seasons (EditableSeasonText, kind==='premise' edit mode) — a "✦ Refine to scope" button + ↩ Revert in the edit footer; the refine/revert buttons use onMouseDown preventDefault so the textarea's onBlur={commit} doesn't save-and-exit before the click lands.

    • "+ new show" end-to-end auto-flow (NewShowAdminCardautoGenerateShowInUniverse) — clicking "+ new show" on /graphene now produces a fully-playable show in ~90-120s. Synchronous portion (~15-25s, shown via stepped progress on the button: "Generating concept…" → "Planning chapters…" → "Landing…"): (1) LLM call generates title + premise + genre + mode, (2) LLM call plans 6-12 chapters in the universe's shape via the universe prompt_directive, (3) inserts season + episodes. Fire-and-forget background portion (~90-120s, tracked via the same startEvent mechanism the manual Kick-It-Off uses so the SeasonClient's pipeline-progress strip lights up automatically): (4) bootstrapBibleFromPlan merges universe canon into a fresh bible, (5) autoRefineNextChapter for chapter 1 — trigger + take 1 + critics + auto-apply findings + refined take 2 + auto-approve bible deltas + promote + ElevenLabs audio. Total cost: ~$0.15-0.20 per click. Schema gotcha fix: the LLM's "novel vs serial" choice now correctly maps to narrative_mode ('bounded'/'serialized'), not the schema-level mode column (which only accepts 'journal'/'novel'/'timelines' per migration 208 — auto-shows are always mode='novel').

    • chapter_hard_rule + cut_landing critic — universe-shape enforcement at prompt + critique time (May 2026) — pure prompt-directive injection wasn't reliably landing the Deep Cut "every chapter ends with a cut!" mechanic; the chapter plan's specific scene direction kept overriding it. Two new enforcement layers stack: (1) Optional StoryUniverse.chapter_hard_rule: string injected at the VERY END of callChapterPromptGPT's system prompt (just before deltaSchema) — highest-attention slot the LLM reads last before generating. Universes without a hard rule see no change. (2) New cut_landing critic in script-critics.ts reads the last ~25% of generated prose and hunts for failure modes (no cut at all, bare cut without frame reveal, frame revealed without new POV, fourth-wall break). New UNIVERSE_EXTRA_CRITIC_KEYS registry maps universe key → critic keys that auto-fire for chapters in that universe; deep_cut: ['cut_landing']. autoRefineNextChapter looks up season.universe + concats the extras into the base critic set so the cut! critic fires automatically for every Deep Cut chapter. Companion Deep-Cut-only diagnostic (CutLandingDiagnostic + POST /:id/diagnose-cut-landing) runs the critic across every triggered chapter's live take and reports per-chapter verdicts inline ("✓ landed" / "✗ N issues with quoted passages") so admins can verify the universe shape without drilling into per-chapter takes modals.

    • Writer fleet rotates universes (May 2026)writer-fleet-cron.ts now picks a universe per scaffold via configurable env WRITER_FLEET_DEEP_CUT_PROBABILITY (default 0.25 = 25% of new fleet drops route to Deep Cut, 75% stay standalone). planPersonaSeason accepts an optional universe field and PREPENDS the universe's prompt_directive to the system prompt before the generic planning instructions — chapter outlines come out shaped for the universe from the start (Deep Cut spines invite cut! reveals, CDA cameos, commercial frames) instead of needing re-planning later. Auto-from-universe flow (commit 4c016aaf) was also fixed: LLM's "novel vs serial" choice now maps to narrative_mode ('bounded'/'serialized') instead of the schema-constrained mode column ('novel'/'journal'/'timelines'); see ARCHITECTURE.md known landmines for the conflation gotcha.

    • Prose Versions — universal restorable history beyond takes (2026-05-27) — extends the season_text_versions (227) pattern to PROSE surfaces, closing the audit gap where 5 mutation paths overwrote prose without snapshots. Migration 321_prose_versions.sql creates one append-only table covering 4 surfaces: episode.chapter_prose, episode_take.prose, season_segment.text_content, season_script_take.segments (JSONB serialized to text). Each row carries prose_before + prose_after + operation (generation | promotion | repair | segment_override | take_deletion | manual_edit) + reason_key (leak_fix_envelope, take_promote, voice_override, timeline_persist, restore, etc) + triggered_by (user | cron:<name> | pipeline:<stage>) + metadata. Helper services/prose-versions.ts exports snapshotProse() (best-effort insert; dedup on no-op except for segment_override where metadata change matters) and restoreProseVersion() (snapshots current value first, so restore is itself undoable — same loop-of-history pattern as restore-text). Wire points (the 5 audit gaps): (1) prose-leak-scanner.ts snapshots before each of the 3 in-place repairs; (2) season-novel-script.ts snapshots before the legacy persist write at chapter cache miss; (3) timeline-seasons.ts snapshots before path-aware chapter prose persist; (4) story-seasons.ts /segments/:segId/voice + /direction snapshot the voice_id/voice_direction metadata change; (5) episode-takes.ts promoteChapterTake + season-takes.ts promoteTake snapshot the prior canonical surface BEFORE the take's prose is copied over (catches the case where a leak repair landed between two takes — the takes table itself wouldn't capture it). Take rejection is soft-delete only (status='rejected', row persists), so no synthetic snapshot needed — the Versions panel unions prose_versions with story_episode_takes directly. Routes: GET /api/story-seasons/:id/prose-versions (season-scoped, returns versions + chapter_takes for union), GET /api/story-seasons/episodes/:episodeId/prose-versions (chapter-scoped), POST /api/story-seasons/prose-versions/:versionId/restore (super-admin), POST /api/story-seasons/prose-versions/restore-take-prose { take_id } (super-admin; promotes a soft-rejected take's prose back to chapter_prose without re-promoting the take row). UI: 📜 Versions button in the SuperAdminPanel "More tools" cluster opens ProseVersionsModal.tsx — unified chronological timeline of prose_versions + chapter_takes for one season, chapter filter, expandable per-row before/after diff panes, one-click Restore on every entry. Versions older than the start of snapshotting are simply absent from history (not synthesized retroactively) — the value of this system is forward-going.

    • One-click "Publish to Spotify" (2026-05-28) — single super-admin button that takes a show from "audio rendered" to "live + compliant on the feed" without hand-walking the steps. Orchestrator season-publish-to-spotify.ts publishSeasonToSpotify(): (1) mode-aware audio gate (compiled season_publishings.audio_url OR any story_episodes.chapter_audio_url — novel shows feed off per-chapter audio; hard-errors with neither); (2) generates the square 3000×3000 podcast cover from the poster if missing (generatePodcastCover); (3) generates the Spotify submission copy if missing (generateSpotifySubmission); (4) publishes to the RSS feed (sets podcast_published_at, creating the season_publishings row if a chapter-only novel show lacks one). Each step skips when already done; returns a per-step summary + cost. It does NOT create a standalone Spotify listing (human-gated portal — no API); but the Graphene network feed is already a live Spotify show crawled ~hourly, so once published the show's chapters appear inside it within ~an hour. Route POST /api/story-seasons/:id/publish-to-spotify (super-admin, wrapped in a podcast_publish pipeline event). UI: green Spotify-glyph "✨ Publish to Spotify" button at the top of the SeasonDistributionPanel with a confirm (warns about the ~$0.08 cover + ~$0.03 copy spend when those need generating) + a per-step result card. The granular RSS/validate/chip controls below remain for step-by-step driving.

    • "Listen on Spotify" badges — network + per-show (2026-05-27) — surfaces a Spotify-brand-green "Listen on Spotify" pill (inline SVG wordmark glyph, self-hides when no URL) in two places. Reusable component SpotifyListenBadge.tsx. Network (/graphene hero): driven by GRAPHENE.spotifyUrl in lib/graphene.ts — hardcoded default, overridable via NEXT_PUBLIC_GRAPHENE_SPOTIFY_URL (Vercel), same brand-asset pattern as coverImageUrl. Per-show (season page hero, public to all listeners): backed by migration 322 adding story_seasons.spotify_show_url (nullable; submission is human-gated so the canonical open.spotify.com/show/<id> link can't be derived, it's pasted in). Admin sets it via a new URL input in the SeasonDistributionPanel which POSTs { spotify_show_url } to the extended POST /api/admin/show-distribution/:seasonId route (admin.ts — now branches on spotify_show_url in body vs. the platform-toggle body; validates the value is an open.spotify.com link). The public badge renders on /seasons/[id] only when the column is populated. Distinct from spotify_submitted_at (migration 292) which only tracks WHEN a show was submitted, not its resulting URL.

    • Per-episode <itunes:image> in RSS + inline SeasonDistributionPanel (2026-05-27) — closes the "every episode shows the generic Graphene hexagon on Spotify" gap. Two changes ship together: (1) season-distribution.ts buildPodcastRss() now emits <itunes:image href="..."> per <item> using story_seasons.podcast_cover_url (the square 3000×3000 from migration 309) — falls back to the channel-level inheritance when no cover is set. The original skip-reason ("posters are 1024×1792 portrait, fail Apple's ≥1400×1400 SQUARE validator") no longer applies since migration 309 added the dedicated square cover. Most impactful on the network-wide mixed feed where dozens of chapters across shows previously inherited one icon; now each chapter visually identifies with its source show even in a mixed feed. Per-show feeds also benefit (defensive — even if the channel-level cover ever drops, the item-level is explicit). (2) New SeasonDistributionPanel.tsx renders inline on /seasons/[id] inside the SuperAdminPanel — collapsible <details> block with the per-show RSS URL + Copy button, a 🔍 Validate feed ↗ deeplink to castfeedvalidator.com (URL pre-filled), 🎵 Open Spotify ↗ + 🍎 Open Apple ↗ portal buttons, 6 live pre-flight checks (chapter audio rendered count, podcast cover present, subtitle / category / keywords set, LLM submission copy cached), ✨ Generate submission copy one-clicker hitting the existing POST /api/story-seasons/:id/spotify-submission, and clickable Apple/Spotify submission-state chips that toggle apple_podcasts_submitted_at / spotify_submitted_at via POST /api/admin/show-distribution/:seasonId. Default-open when any platform is pending OR pre-flight has a ⚠; collapses itself once both chips are ✓. Lives between InlineRunsFeed and the Tools row in SuperAdminPanel. Zero new backend routes — reuses what migrations 266 (per-show RSS) + 292 (submission scalars) + 310 (LLM copy cache) already shipped.

    • Spoken graphene.fm outro CTA — distribution-only audio for the PUBLIC feed (2026-05-29, migration 327) — closes the gap that the only "come back to the app" nudge was text in the show notes (most Spotify listeners never read those). The constraint: the on-site reader and the RSS enclosure serve the SAME MP3 (story_episodes.chapter_audio_url), so appending a "come to graphene.fm" line to it would also play it to on-site listeners who are already there. Solution = a separate, distribution-only audio variant, never a runtime flag. Migration 327 adds story_episodes.chapter_audio_distribution_url / _byte_length / _duration_seconds + story_seasons.podcast_distribution_cta_enabled (default false) + podcast_distribution_cta_text (optional verbatim override; {title} token). Render (season-novel-chapter-audio.ts): the post-narration tail (bumpers→master→ID3→probe) was extracted into a local finalize() so it runs twice — clean (no CTA → chapter_audio_url, unchanged) and, when the show opted in, a distribution variant = concatMp3Buffers([narrationBuffer, ctaTts]) (CTA appended before the outro music so the music buttons it) → finalize() → uploaded to audio/${seasonId}/chapters-dist/ep-N.mp3 → distribution columns. The CTA line is one short ttsSegment() in the narrator's voice (falls back to DEFAULT_NARRATOR_VOICE); the shared master step loudness-matches it. Best-effort: any CTA failure logs and leaves the distribution columns untouched (feed falls back to clean). Copy is state-aware (podcast-distribution-cta.ts buildDistributionCtaText): in-flight stories (status !== 'completed') say "the next chapter is being written — come shape it"; completed stories pivot to "more stories." graphene.fm is spelled "graphene dot f m" for TTS. Public-feed swap (season-distribution.ts): the chapter <enclosure> uses the distribution columns only when !opts.forSubscriber && season.podcast_distribution_cta_enabled && chapter_audio_distribution_url — so the on-site player, the paid subscriber feed, and any not-yet-re-rendered chapter all fall back to the clean audio. The toggle is the master switch: disabling it reverts the public feed instantly with no re-render. Network-feed CTA cap: the mixed "Graphene" feed (no opts.seasonId) plays many shows back-to-back, so the CTA would repeat the same destination every chapter in a binge. A pre-pass (latestDistEpByShow) marks the newest distribution chapter PER SHOW, and in the network feed the CTA rides only that one (ctaAllowedHere) — at most one CTA per show, landing on the live edge. The per-show feed is exempt (a single-show subscriber gets the outro every chapter). Pure feed-assembly — both audio variants already exist per chapter, no re-render. Config UI in MusicBumpersModal.tsx (a "🎙 Podcast outro CTA" section — toggle + override textarea whose placeholder shows the live state-aware default) via PATCH /api/story-seasons/:id/podcast-cta. Preview in the Share Kit (distribution_audio_preview from the share-kit GET → an inline <audio> of the first rendered distribution chapter) so the operator hears exactly what ships before distributing. NOT in scope: trailing-edge publishing (Spotify lags the app's live edge — a product decision), journal-mode distribution audio, automated backfill (existing chapters get the CTA on their next re-render).

    • Prose-leak scanner — recurring safety net behind the strip helpers (May 2026) — new prose_leak_scan_tick hourly cron in apps/backend/src/services/prose-leak-scanner.ts, wired in index.ts with a 90min lookback window (30min overlap buffer against backend restarts). Three detection modes run against every recently-updated row on four surfaces: (1) envelope — markdown-fenced JSON envelopes (the v2.2 hotfix class) via stripJsonEnvelopeFromProse; (2) delta_suffix — trailing bible_delta JSON glued onto prose via stripBibleDeltaJsonFromProse (uses the expanded DELTA_SCHEMA_KEYS list — added current_state, inner_life, secret_knowledge, expression_notes, sensory_palette, image_or_phrase, summary_so_far after the 2026-05-23 leak on /seasons/47d8461f.../read showed current_state leaking inline + dangling }/] brackets that the original 278-era list missed); (3) meta_note — planning-language paragraphs ("Opening Image:", "[Note: ...]") via stripMetaNotesFromProse. Surfaces scanned: story_episodes.chapter_prose, story_episode_takes.prose, season_audio_segments.text_content, season_script_takes.segments JSONB. Auto-fix default ON for the first three surfaces (battle-tested in migrations 277/278/281/282); script-takes intentionally scan-only since JSONB rewrites are fiddly and re-promoting the take rewrites segments from clean prose anyway. Every detection writes to service_errors with service='prose-leak-scanner', severity='warning', and metadata carrying surface + episode_id + kinds[] + sample_prefix — so the daily /errors triage flow AND Marlow's Sunday ops report (ops-personas.ts) both surface them automatically (Marlow pulls health checks which include cron status + service_errors). Heartbeat status: 'partial' when leaks were detected (yellow in /system-health — "scanner working but upstream let one through, investigate"), 'ok' when zero detections, 'error' only on scanner-itself failures. Companion migration 282 (282_strip_expanded_bible_keys_from_prose.sql) backfills the expanded key list against already-contaminated rows — same 4-table coverage as 278/281.

    • Awaiting-bucket decay sweep (May 2026) — new awaiting_decay_tick cron in apps/backend/src/services/awaiting-decay.ts, hourly. Auto-archives unreviewed seasons older than 14 days (review_state='unreviewed' + is_published_to_graphene=false + archived_at IS NULL + created_at < now()-14d). Does NOT touch changes_requested or approved shows (those are explicit admin decisions, not abandonment). Rows aren't deleted; admin can restore via PATCH /:id/archive { archived: false }. With ~40+ awaiting and the writer fleet generating hourly, the sweep keeps the review queue actionable instead of accumulating indefinitely. Heartbeat-tracked + CRON_LABEL entry so /dashboard/admin/system-health surfaces decay count + sample IDs per tick.

    • Quick Review triage modal (May 2026)QuickReviewModal replaces per-season click-through review for backlog clearing. Stacked scroll of every awaiting season with title + universe chip + persona byline + premise + first-paragraph preview + per-row Approve / Changes / Skip buttons. Keyboard shortcuts: A approve · X request changes · S skip · J/ next row · K/ previous · Esc close. Verdict actions auto-advance to the next row; focused row gets a left-border + tinted bg highlight, auto-scrolls into view. Mounted on /graphene (admin-filter row gains "📋 Quick review · N" pill, opens flat awaiting list across universes) AND /dashboard/admin/writer-fleet (same modal scoped to the dashboard's current filter set — so admin can pre-narrow to e.g. "🎬 Deep Cut + Pending" and Quick Review only shows those). Backend support: /manageable-version-status + /admin/writer-fleet both surface first_paragraph_preview (first paragraph of ch1 prose, ~240c cap) + writer_handle (persona's profile.username for the byline link). Same review endpoint (PATCH /:id/review) runs cascades identically whether triggered from per-card buttons, bulk-approve, or the modal — no review-path divergence.

    • Universe label resolution server-side (May 2026)/admin/writer-fleet batch-resolves the friendly display label for each distinct universe key in the response via getUniverseByKey (covers DB + hardcoded universes). Response rows gain universe_label. Dashboard frontend dropped its hardcoded UNIVERSE_LABELS map in favor of the server-resolved value, so user-created universes ("The Hotel Diaries", "The Threshold Labyrinth") render with their proper labels instead of raw keys. Future universes inherit automatically — no frontend map to maintain.

    • Awaiting-bucket workflow polish (May 2026) — extends the /graphene admin review queue with one-click sign-off + bulk approval + persona attribution. Backend /manageable-version-status now surfaces review_state, writer_display_name, writer_persona_id, writer_handle, created_at per hidden_season (joins ai_personas where is_platform_writer=true on owner_user_id). New backend endpoint POST /api/story-seasons/bulk-review (super-admin) accepts { season_ids: string[], state } and loops the existing /:id/review handler internally so cascades (fill prose, generate poster, generate persona poses, auto-publish when ready) run identically per season. Frontend GrapheneClient.tsx splits hiddenSeasons into awaitingByUniverse (review_state IN ['unreviewed', 'changes_requested']) + hiddenByUniverse; awaiting cards render INLINE in their universe row (always visible, no toggle) with: amber "AWAITING" ribbon for unreviewed / rose "NEEDS WORK" ribbon for changes_requested; persona byline "By <Name>" that links to /writers/<handle>; relative-time hint ("3h ago"); newest-first sort. Per-card actions: ✓ Approve, ✗ Changes. Per-section: "◐ N awaiting" pill + "✓ Approve all N" button (filters out changes_requested — admin's explicit pushback isn't overridden by bulk).

    • Three-tier model selection for the prose pipeline (May 2026) — formalizes which LLM tier touches which stage and adds a premium re-roll at promote time. Catalog in services/llm.ts gains claude-opus-4-7 (premium, $15/$75 per M tokens) + fallback to gpt-4o, plus two exported constants — PROMOTE_MODEL_KEY (Opus 4.7) and PLANNING_MODEL_KEY (Sonnet 4.6). Planning layer upgrade: knowledge-layer.ts, chapter-spine.ts, and chapter-stakes.ts now route through PLANNING_MODEL_KEY (was hardcoded gpt-4o-mini). One-shot per season, so the bump from $0.005 → $0.05 in absolute cost is amortized across every downstream chapter prose call — better constraints propagate, dramatically below the ~$0.60-1.50 TTS bill per chapter. Promote-time re-roll: promoteChapterTake now defaults upgrade_on_promote=true. When the take being promoted is on the cheap iteration tier (claude-haiku-4-5) AND Opus is configured, it regenerates a sibling take on PROMOTE_MODEL_KEY before locking and promotes that sibling instead. The original take stays as draft so admins can compare in the take history. ~$0.15 of Opus prose against ~$0.60-1.50 of downstream TTS = 10-25% premium on the audio bill for the prose listeners actually hear. Skipped when: caller opts out (upgrade_on_promote: false), take already on a premium tier, or Opus unavailable. The auto-fire "generate next chapter" route at story-seasons.ts:3441 passes false because that path is unreviewed; the PATCH route at story-seasons.ts:4590 accepts the flag in the body so the admin takes UI can expose a toggle. Critics intentionally stay on the cheap tier — 8× amplification makes a Sonnet bump expensive; individual critics can override their model_key if a specific craft check underperforms. See docs/ai/CONVENTIONS.md "Model selection by stage" for the full policy.

    • Kick-It-Off CTA evolution + outcome banner — the novel-mode admin Create-Next button now distinguishes three states: pre-planned chapters left → "Generate Chapter N of TOTAL"; fresh season (0 triggered + 0 planned) → "✨ Kick It Off — Chapter 1"; novela exhausted → "Extend the Novela — Chapter N". busy flag now ties to creatingNextEpisode || pipelineRunning (was just the 3-second click debounce) so the button stays disabled for the full ~60-90s pipeline; label evolves through "Kicking off — Chapter N…" → "Pipeline running — Chapter N…" → idle baseLabel. After the pipeline finishes, an outcome banner above the playlist surfaces success (emerald, "Chapter N ready · take 2 promoted + audio rendered · Ns") or failure (rose, with error string + retry hint, sticky until ack). Gated by sawKickOffRunningRef so parallel improve-all-behind completions don't double-surface (those have their own outcome banner via ImproveAllBehindBanner).

    • "behind v2.0" flash fix on both /seasons/[id] and /read — the per-chapter platform-version chip + the regenerate banner used to flash "N chapters behind v2.0" briefly on every admin's first paint, then hide. Cause: the SSR/anonymous /api/story-seasons/:id and /chapters payloads omit live_take_platform_version (admin-only field), so isVersionBehind(null) returned true for every triggered+prose chapter → both UIs rendered with a misleading count → ~200-500ms later the authed refetch landed with real version stamps → the banner hid. Fix: new adminEpisodesLoaded (SeasonClient) and adminChaptersLoaded (ReadClient) flags flip true after the authed refetch lands (or errors — staying false would hide the banner forever on a failed first fetch). Both UIs now gated on (isSuperAdmin && isNovel && admin*Loaded) so they don't paint until version data is actually present.

    • tech_dystopian framework (Black Mirror lane) — new entry in story-frameworks.ts tuned for procedural dread. Intensity curve [0.25, 0.35, 0.55, 0.85, 0.7] (late spike for the reveal, then trailing dread). prompt_directive covers cold institutional voice (no contractions for state actors, passive constructions for state action), clinical rather than lurid sensory detail, slow-dread-to-revelation structure (early chapters establish plausible normality → wrongness → thread-pulling → reveal that recontextualizes earlier scenes → final chapter does NOT resolve neatly, "the system continues, the protagonist may not"), and a do-not-use list (gore, action setpieces, comic relief, neat catharsis, the words "dystopia"/"Orwellian," tech beyond a 5-year horizon). Beats: Plausibly Normal → A Wrongness → Pulling the Thread → The Reveal → The System Continues. Pairs naturally with the turing_logs universe (which sets it as default_framework_key).

    • Cross-device resume-where-you-left-off — listener returns to a season on any browser/device they're logged into and the player jumps to the chapter + timestamp where they left off. Migration 122_listen_resume_cursor.sql adds chapter_listen_sessions.last_progress_seconds (replaced on every progress event, NOT ratcheted — distinct from max_progress_seconds which is the "deepest ever" signal used for completion tracking). Backfill copies max_progress_seconds into last_progress_seconds for existing rows. Service chapter-listen-tracking.tsrecordListenEvent now writes both fields on every event; new getResumeCursor(seasonId, identity) returns the listener's most-recently-touched session row joined with the chapter's title + active take so the front-end can render a "Resuming Ch3 at 4:23" hint AND detect when the take has been promoted since the listener last listened (take_changed flag → optional "story has been updated" notice). Cross-device works for authenticated users automatically (rows keyed on user_id); anonymous listeners use a per-browser session_id so cross-device resume isn't possible (by design). Route: GET /:id/resume-cursor (auth optional, anonymous via ?session_id=; never errors visibly — returns { cursor: null } on transient failures so page load isn't blocked). ChapterPlaylistPlayer accepts initialChapterNumber + initialPositionSeconds props — selects the matching chapter on mount, then seeks the audio element to the resume position once metadata loads (capped at duration - 1s so it doesn't auto-advance immediately). Resume is suppressed when the chapter is already completed=true so listeners don't get bumped to the very end of a chapter they've finished. Page surface: small inline ↻ Resuming Ch3 · Title at 4:23 hint above the playlist player when a cursor is active and not-yet-completed.

    • Per-chapter, per-version listen tracking — captures "how many listens" AND "which version was heard" by tagging every listen session with the take_id that was live at listen-start. Migration 121_chapter_listen_sessions.sql adds chapter_listen_sessions (one row per (identity, episode, take) combo; identity is authenticated user_id or anonymous session_id from the same localStorage UUID used by ratings; partial unique indexes enforce dedup; max_progress_seconds ratchets up; completed flips at 90% threshold; play_count increments on each new 'start' for replay signal). Service chapter-listen-tracking.tsrecordListenEvent upserts + ratchets max-progress (never decreases); summarizeListenStats returns per-chapter aggregates broken down by take with unique listeners / completed listeners / avg progress / median progress / total plays / last-listen timestamp, plus season-level rollups (total unique listeners, avg chapters reached per listener). Routes: POST /:id/episodes/:n/listen-event (auth optional, anonymous via body session_id; fire-and-forget — returns 200 even on transient failures so the player never sees errors), GET /:id/listen-stats (super-admin aggregate dashboard data). Frontend: ChapterPlaylistPlayer accepts a new onListenEvent callback prop wired by /seasons/[id]/page.tsx to POST listen events. The player throttles progress updates to one per 10 seconds, fires 'start' on play (once per chapter mount), 'complete' on chapter end, and a final 'progress' on pause. Uses fetch({ keepalive: true }) so the last event survives page-unload. Admin view: new ListenStatsAdminModal.tsx opened by 📊 Listens button in SuperAdminPanel tools row — season summary strip (unique listeners / avg chapters per listener / chapters tracked) + per-chapter cards with per-take rows showing listeners / completed / plays / avg progress / median progress / last-listen-at. Live takes are sorted to the top and labeled ← live so admins can compare engagement of the current version against archived versions ("v1 had 100 listeners at 65% avg; v2 has 80 listeners at 88% — promote was the right call"). Companion to the satisfaction ratings (migration 120) — ratings are end-of-novel one-shot signal, listens are per-chapter per-version with progress.

    • End-of-novel listener satisfaction ratings — captures finisher signal: thumbs + stars + optional free-text feedback when a listener reaches the end of the chapter playlist. Migration 120_season_listener_ratings.sql adds season_listener_ratings (one row per listener per season — identity is either authenticated user_id or anonymous session_id from a localStorage UUID, partial unique indexes enforce one rating per identity per season; stars constrained to 1-5 and only when thumbs='up'; RLS allows anonymous insert with session_id, public read on visible seasons for the aggregate badge, super-admin manage). Service season-listener-ratings.ts: submitListenerRating (upsert — re-rating updates the existing row), getMyRating (frontend uses to suppress re-prompts), summarizeRatings (count + thumbs split + percent + avg stars + 1-5 distribution + show_publicly flag that's true once count ≥ 5), listRatingsForAdmin (full list with feedback text for moderation). Routes: POST /:id/listener-rating (auth optional, anonymous via body session_id), GET /:id/listener-rating/me (auth optional, anonymous via ?session_id=), GET /:id/listener-rating-summary (public), GET /:id/listener-ratings (super-admin only). Frontend: new ListenerRatingModal.tsx — two-phase flow (👍/👎 first; if 👍 then 1-5 star picker with hover-fill + "what worked?" textarea; if 👎 then "what didn't work?" textarea, no stars). Auto-shown when ChapterPlaylistPlayer fires its new onPlaylistEnded callback (the listener finished the last chapter) AND the listener hasn't already rated this season. Aggregate badge below the playlist: 👍 87% · ★ 4.3 (24) rendered only when show_publicly is true (count ≥ 5) to avoid small-sample noise. Admin view via new ListenerRatingsAdminModal opened by ⭐ Ratings button in SuperAdminPanel tools row — summary strip (count / thumbs / avg stars / star distribution) + filter chips (all / 👍 / 👎 / with text) + per-rating cards showing thumbs + stars + text + author + relative time. Listener identity stays anonymous-friendly (no login required at end-of-novel; localStorage session_id under key hj_listener_session_id enables cross-visit dedup for anonymous listeners).

    • Bulk novel production pipeline (auto-refine ALL + queue audio for all) — separates the cheap GPT-only "draft the whole novel" pass from the expensive ElevenLabs "render audio" pass so admins can review prose before paying for TTS. Two new service functions in season-novel-chapter-auto.ts: (1) autoRefineAllChapters(seasonId, opts) loops over every untriggered chapter calling autoRefineNextChapter with the new skipAudio: true option — each chapter gets the full critique-and-iterate flow but no TTS. Per-chapter ~30-60s; ~3-5 minutes total for a 6-chapter novel. Failed chapters are auto-untriggered to prevent infinite-loop on a broken chapter; the loop continues to the next. (2) renderAllChapterAudios(seasonId, opts) loops over every chapter with prose, calling renderChapterAudio for each. Skips chapters that already have chapter_audio_url unless force: true. Per-segment TTS cache reuse (commit 14e287a) keeps cost low on re-runs. Routes (super-admin, fire-and-forget, pipeline-event tracked): POST /:id/auto-refine-all-chapters (body { critic_keys? }) and POST /:id/queue-all-chapter-audio (body { force? }). UI: two new bulk buttons in EpisodesAdminModal header alongside the existing single-chapter buttons — ✨✨ Auto-refine ALL chapters (fuchsia, primary action for full-novel drafts) and 🎙 Queue audio for all (purple, ⇧Shift to force re-render of already-rendered chapters). Confirm dialog spells out the chapter count + time estimate so admins know what they're triggering. Existing ✨ Auto-refine next (single chapter, with audio) renamed for clarity. Net workflow: draft entire novel cheaply → review prose in the chapter playlist + read page → queue audio when satisfied → listen.

    • Collapsible chapter critique history — gives admins an audit trail of every critic run across every take for a chapter, in one collapsible panel inside ChapterTakesModal. Especially valuable post-auto-refine since that pipeline runs critics + applies findings invisibly behind the scenes; the history surface makes that work visible. New aggregator listChapterCritiqueHistory(episodeId) returns chronologically-ordered [{ take, critiques, decisions }] with three queries (takes + critiques + decisions, all filtered by the chapter's take_ids). Route: GET /episodes/:episodeId/critique-history (super-admin). UI: 📜 Critique history <details> panel above the prose in ChapterTakesModal. Lazy-loaded — fetches when first opened, refreshed automatically when the admin runs a new critique or selects a different take so the badge counts (N takes, M critique runs, K findings) stay current. Each take is a nested collapsible showing critic name + finding count + applied/ignored counts; clicking a finding's parent critique expands the per-finding cards (¶ index, severity, quote, problem, suggestion, decision badge — applied/override/ignored/pending). Promoted takes show promoted Xh ago in green; archived/rejected takes show their status chip. "View →" button on non-current take rows switches the main view to that take so admin can compare prose side-by-side.

    • One-click auto-refine pipeline for novel chapters — turns "create new chapter" into "listen to a refined version of that chapter" with a single button. Service season-novel-chapter-auto.ts autoRefineNextChapter(seasonId, opts) orchestrates: (1) find next untriggered chapter, (2) trigger, (3) generateChapterTake for take 1, (4) run DEFAULT_CRITIC_KEYS (tension_editor / dialogue_doctor / repetition_hawk) in parallel against take 1, (5) auto-apply every finding via recordChapterCritiqueDecision({ action: 'apply' }), (6) auto-approve any pending bible deltas from take 1 (canon advances), (7) generateChapterTake for take 2 with apply_critiques_from_take_id = take1.id so the critic notes fold into the new prompt, (8) auto-approve take 2's deltas, (9) promoteChapterTake for take 2 → chapter_prose updates + segments rebuild, (10) renderChapterAudio → per-chapter MP3 with cache_segments reuse for unchanged TTS. ~60-90s wall-time for a typical 6-chapter novel. Route: POST /:id/auto-refine-next-chapter (super-admin, fire-and-forget, 202 response, pipeline-event tracked under full_pipeline stage so it shows in the runs feed). Body accepts { critic_keys?: string[] } to override the default set. UI: new ✨ Auto-refine next chapter button in EpisodesAdminModal header — sits next to a renamed 📖 Generate (no refine) button (the existing path that does just trigger+take+promote without the critic loop or audio). Confirm dialog spells out the 7-step pipeline so admins know what they're triggering. The runs feed shows progress as [auto-refine] ... log lines; completion signal is the new chapter's chapter_audio_url populating on the season page playlist (commit b3a7d19's playlist player picks it up automatically). Closes the loop on the full Story Bible + chapter-takes plan: every step the admin used to do manually (generate, critique, apply, iterate, promote, render audio) is now sequenced into one click.

    • Novel-mode chapter playlist player — eliminates the master compiled.mp3 step for novel-mode seasons. Adding a new chapter no longer re-renders older chapters at all (not even the cheap concat/upload of the master): each chapter is its own MP3 and the season-level player plays them as a click-to-jump playlist. renderSeasonAudio (in season-audio.ts) detects season.mode === 'novel' and skips the ffmpeg concat + Supabase upload of compiled.mp3. The per-chapter byproduct phase (commit 262db32) becomes the primary output: each story_episodes.chapter_audio_url gets stamped, and season_publishings.audio_url stays null (signals "playlist mode" to the front end). season_publishings.audio_duration_seconds is still populated, summed from per-chapter durations, so total-runtime UIs keep working. New component ChapterPlaylistPlayer.tsx — sequential <audio> playback with auto-advance on onEnded, prev/next chapter buttons, click-to-jump chapter list, "Now playing · Chapter N of M" header with cumulative elapsed time across all chapters. The /seasons/[id] page conditionally renders the playlist player above the existing single-file player — only when season.mode === 'novel' AND there are chapters with chapter_audio_url. Existing journal-mode flow unchanged. Cost reduction: each new chapter generates only its own MP3; older chapters' MP3s are cached on storage and served as-is. Storage churn drops from "every render uploads a new compiled.mp3 of the entire novel" to "render adds one new ~MB chapter file."

    • Character pronouns — stop the narrator defaulting to they/them (CharacterPronounsModal.tsx) — adds an optional pronouns string to BibleCharacter (story-bible.ts). buildBiblePromptBlock leads each character's identity line with its pronouns (Alex (she/her, INTJ, 27, intro Ch1)) and appends a PRONOUNS guidance block: use stated pronouns consistently, and for an individual with NO stated pronouns infer ONE consistent gendered pronoun rather than defaulting to singular they/them. Editable via a dropdown (Auto / she-her / he-him / they-them / it-its) in a modal surfaced on the read page (⚥ Pronouns button in the sticky header, gated on canMarkup = owner OR super-admin) and in the character editor (StoryBibleModal header). Routes (creator/super-admin via requireSuperAdmin which accepts is_creator): GET /:id/characters (key/name/pronouns projection), PATCH /:id/character-pronouns {key, pronouns}, and POST /:id/characters {name, pronouns}add a character the bible is missing (common: the bible tracked only faction/placeholder names and never the protagonist, so there was nothing to set pronouns on; the modal has an "Add a character" row for exactly this). All commit a versioned bible edit (commitBible). Affects FUTURE chapter generation only — already-rendered chapters need a re-render to update prose (the TTS reads prose verbatim, so the they/them originates in generation, not narration). Two in-modal shortcuts close the loop: ↻ Rebuild characters from prose (POST /:id/bible/backfill — re-extracts the whole character list when the bible only had placeholder/faction names; backfillBibleFromChapters now preserves manually-set pronouns by name-match so a rebuild never drops them) and, when opened from the reader, two chapter-level fixes: ✎ Fix pronouns in Ch N — a surgical swap that keeps the existing prose (POST /:id/episodes/:n/fix-pronounspronoun-fix.ts fixChapterPronouns, a constrained claude-sonnet-4-6 copy-edit at temp 0.2 that changes ONLY the listed characters' pronouns + agreement, with a length guard rejecting any output <85% of the original so a rewrite/truncation can't clobber the prose; writes chapter_prose + re-renders audio with segment-cache reuse, mirroring repair-prose) — and ↻ Re-render Ch N, the heavier full regeneration (POST /:id/episodes/:n/generate-chapterautoRefineNextChapter on the existing chapter). The modal receives currentChapter = the reader's nowReadingChapter. Use Fix for "just correct the pronouns," Re-render for "rewrite the chapter."

    • Bible character dialogue scaffolding — extends BibleCharacter with optional mbti (4-letter), age, verbal_tics (things they DO say), speech_avoid (things they DON'T say), backstory (paragraph-length formative context), and relationships (per-other-character speech dynamics). All additive on the JSONB column — older bibles parse fine. The chapter prompt's buildBiblePromptBlock renders these inline per character: identity line shows Mara (INTJ, 34, intro Ch1), dialogue fields appear as speaks like: / avoids: after the existing voice line, and backstory: + relationships are shown only on the chapter where a character first appears (orientation budget). Bootstrap and backfill GPT prompts now request these fields in the same call (zero added cost). The bible delta schema accepts update_characters[].verbal_tics / speech_avoid / relationships so the model can sharpen speech patterns mid-novel as it observes how a character actually talks under stress. UI in StoryBibleModal Current tab shows MBTI as a cyan chip and renders the new fields with their own FieldLine rows. Chosen lightweight (4-letter code, model interprets freely) over heavyweight (deterministic per-MBTI dialogue rule lookup) for flexibility — can move to heavy later if dialogue feels generic. No persona linking — Q on whether to link novel characters to AI personas (with their own backstories, voice IDs, real-time context) was answered NO for v1: the cost (persona pollution, voice-budget pressure, split-brain ownership of arc/secrets, blurred mode lines) outweighs the wins for a sandbox-iteration use case. The optional ai_persona_id per-character linkage is a future move when characters earn it.

    • Polish + audit (PR6 of story-bible-and-chapter-takes plan) — final pass to make every chapter prose change traceable to a take. The legacy generate-chapter + generate-next-chapter routes (apps/backend/src/routes/story-seasons.ts) used to call generateChapter / generateNextChapter from season-novel-chapter.ts, which wrote directly to story_episodes.chapter_prose. Both routes now orchestrate via the takes layer — they call generateChapterTake followed by promoteChapterTake, leaving a take row + bible delta + (when iterating) consumed critique decisions behind every prose change. Behavior to callers (the existing 📖 Generate next chapter and ✍ Generate prose UI buttons) is unchanged: same request shape, same response shape, same auto-promote semantics. The takes modal is the deliberate path when admins want explicit iteration without auto-promote. The legacy generateChapter/generateNextChapter functions in season-novel-chapter.ts still exist for any internal callers but are no longer wired to the routes — new code should use the takes service directly. Cost/latency dashboard added to ChapterTakesModal header showing total takes, cumulative cost, and cumulative latency for this chapter (excludes synthetic legacy_v1 rows which carry no cost data). With this PR the full plan ships: bible (PR1) + bible deltas (PR2) + bible backfill (PR3) + chapter takes (PR4) + chapter critiques (PR5) + audit (PR6).

    • Per-character voice mapping for dialogue (audiobook-creator-suite Phase 1 flagship) — the differentiator vs ElevenLabs Studio for audiobook creators. Detects attributed dialogue ("X," said Pell., Pell said, "X.", and verb-of-speaking variants: replied / asked / shouted / whispered / etc.) in chapter prose at TTS render time, attributes it to bible characters by case-insensitive substring match (longest-name wins, so Auditor Pell beats Pell when both are candidates), and routes the dialogue span to the mapped voice — falling back to the narrator when no character is mapped or no attribution is detected. Migration 285 adds season_character_voices keyed on (season_id, character_key) with voice_id, voice_direction, character_name (denormalized so the UI can flag "orphaned" mappings when a bible character is renamed/deleted but the mapping survives), notes. Super-admin RLS matches the lexicon / bible / takes pattern. Service character-voices.ts — CRUD + detectDialogue(text, characters) (regex on quote-then-verb-then-name and name-then-verb-then-quote patterns, smart-quote-aware, dedupes overlaps) + splitProseByVoice(text, narratorVoiceId, narratorDirection, seasonId) returns VoiceSpan[] alternating narrator + character voices. 60s in-memory per-season cache mirrors pronunciation-lexicon.ts so a 30-segment chapter render queries once not 30 times. Render integration at both season-scoped TTS callsites: season-novel-chapter-audio.ts (chapter audio render — novel-mode primary path) and season-audio.ts renderSeasonAudio (segment render — journal-mode / script-take path). Same integration shape in both: per-segment TTS step calls splitProseByVoice post-lexicon, then for single-span output uses the fast path (one ttsSegment call as before), for multi-span output runs Promise.all parallel TTS per span and concatenates via the concatMp3Buffers helper (ffmpeg concat demuxer with stream-copy — no re-encode since all spans come from the same ElevenLabs model). Cost: each dialogue span = one extra TTS call; a chapter with ~30% dialogue ends up ~1.3-1.5× narrator-only render cost since the per-span TTS calls run in parallel. Routes on /api/story-seasons: GET /:id/character-voices (list), POST /:id/character-voices (upsert keyed on character_key — re-assigning replaces in place), DELETE /character-voices/:assignmentId. UI CharacterVoicesModal.tsx opened via 🎭 Voices button in SuperAdminPanel tools row — fetches bible + assignments + voice bank in parallel, renders a row per bible character with a voice picker per row (saves on change), surfaces "orphan assignments" section when mappings reference characters no longer in the bible (renamed / deleted) so admin can clean up. Empty state prompts to bootstrap the bible first when characters list is empty (attribution is meaningless without character names to match). NOT in v1: LLM-based attribution for ambiguous cases, pronoun attribution ("he said"), mid-paragraph dialogue without tags, per-line voice direction overrides (uses the character's default), pulse-layout integration (renderIntentPulse already has its own per-voice routing via the persona system).

    • Per-show share kit (/dashboard/admin/share-kit/[id]) — consolidates every asset and URL needed to post about a show across channels into one page. After 8 marketing-automation PRs (vertical trailer, captions, bulk render, auto-caption, render-complete emails, RSS submission tracking, Bluesky autopost, engagement scrape) the assets exist but each lives in a different surface; the share kit is where you go when the work is "I want to push this show today." Sections: (1) Hero — poster + title + premise + a warning chip when not yet published to Apple/Spotify; (1.5) 🚀 Post today pack — the guided do-it-now block directly under the hero: three steps in posting order — short-form clip (trailer download + top-clip caption from clip_hooks[0]), r/audiodrama community post (Generate/Re-draft + copy title/body), and "everything points to the app" (the season link + Spotify link if set) — each copy-ready so the daily post is ~30s, not 10 min. The community post is LLM-drafted (community-post.ts, claude-sonnet-4-6) leading with the interactive letters hook + genre, never "AI"; cached on story_seasons.community_post_draft (migration 326) so re-opening costs $0. The detailed asset sections below stay as the full toolbox; the pack is the fast path that mirrors the distribution doctrine (free taste on open platforms → letters/subscription live in the app). (1.7) 🎬 Video idea queue — an LLM-drafted runway of 10 short-form (TikTok/Reels/Shorts) video ideas tuned per show, across distinct formats (POV / what-if / maker-explainer / pullquote-led / mechanic-demo / cold-open / behind-the-scenes / etc.). Each idea ships with a 1.5s hook, on-screen text, post caption (with {{LINK}} token), clip source, and a production note. Cached on story_seasons.video_idea_queue (migration 328) via POST /api/admin/share-kit/:seasonId/video-ideas (video-ideas.ts, ~$0.05 on claude-sonnet-4-6) so re-opening costs $0. Pulls real chapter pullquotes from story_episodes.pullquotes into the prompt so pullquote-led ideas reference actual dialogue rather than inventing it. Solves the "blank queue panic" that ends most channels by week 2. Posted-state tracker (2026-05-30): each idea carries a posted_at: { tiktok, reels, shorts, bluesky } object — per-card pill row toggles the timestamp via PATCH /api/admin/share-kit/:seasonId/video-ideas/:index/posted (optimistic UI), and the queue header shows "X/10 in motion" with per-platform counts. So the queue survives multi-week usage instead of being amnesiac. Auto-render MP4 (2026-05-30): for clip-eligible ideas (any whose clip_source references a chapter — pullquote_clip, cold_open, character_moment formats), a per-card "🎬 Render MP4" button kicks an ffmpeg job (video-idea-render.ts) that downloads the show poster + chapter audio, scales the poster to 1080×1920 with a gentle zoompan, burns the idea's on-screen text + a graphene.fm watermark over a dark scrim, cuts ~18s of audio starting past the intro music + chapter announcement (so it lands in the prose, not the heading), fades the last 1.5s, and uploads to season-assets/<seasonId>/video-ideas/idea-<index>.mp4. URL stamped onto the idea's rendered_video_url; the card swaps the button for an inline <video> preview + a Download MP4 link. Reuses getFontPath + the sanitization/storage patterns from season-trailer-vertical.ts. v1 caveat: doesn't try to align the audio cut to the exact pullquote line; uses the first ~18s of prose. Self-filmed / text-card-only / trailer-based ideas are intentionally not eligible (operator uses the existing trailer or shoots manually). Bluesky video autopost (2026-05-30, video-idea-bluesky.ts + extended bskyPost in cafe-bluesky.ts): rendered ideas get a "🦋 Post to Bluesky" button that uploads the MP4 via Bluesky's async video pipeline (app.bsky.video.uploadVideo → poll getJobStatus until JOB_STATE_COMPLETED, ~30-60s), then creates a post with a native app.bsky.embed.video embed (9:16 aspect, alt = on-screen text). Stamps idea.bluesky_post_uri + idea.posted_at.bluesky on success so the BS pill flips automatically and the card swaps the button for a "🦋 On Bluesky ↗" deep-link to the bsky.app post. Bluesky is the one platform we can natively autopost video to (TikTok/Reels/Shorts content-posting APIs are gated/risky); this closes the loop from "video idea" to "live on a public platform" with a single click. New BSKY_VIDEO_BASE, BSKY_VIDEO_MAX_BYTES (50MB), and polling constants govern the upload; failures fall through (post without embed or skip), so a Bluesky outage never blocks the queue. (2) Vertical trailer — inline player + Download MP4 + Copy URL + the auto-drafted TikTok caption with one-click Copy; (3) Horizontal trailer — same shape, for /seasons landings + paid ad creative; (4) Per-show RSS URL with one-click copy + Apple/Spotify submission chips that link to the submit portals and show ✓-with-date when marked (mirrors /dashboard/admin/podcast-feed toggle); (5) Landing pages — organic permalink (graphene.fm/seasons/id), paid landing (/listen/id) + a collapsible UTM-variants table for Meta/Reddit/TikTok/Google/Apple/Pinterest (same six sources as PaidTrafficUrlPanel); (5.5) 💬 Reel / post comment (2026-06-09) — ready-to-paste comments to drop under your OWN boosted reel/short to convert a viewer into a listener (three variants: listen-free, shape-the-story, link-in-bio-safe — all lead with the interactive letters hook, never "AI"), with the /listen/id landing URL baked in. Tagged utm_medium=social (NOT cpc) so these free comment clicks never blur into paid-ad attribution, plus a distinct utm_source per platform (facebook=reel-comment / instagram / tiktok / youtube, in a collapsible "tagged link per platform" list). Pure client-side templating (reelComments() + ORGANIC_SOURCES) — instant + free, no LLM call (unlike the community-post / video-idea generators); reuses the same slugify + show-{slug} campaign convention as the paid variants. Solves the trap where reusing a paid /listen UTM link in a comment would book organic traffic as CPC; (6) Bluesky — last 5 chapter-drop posts with ♥/↻/💬 engagement + bsky.app deep links + autopost status. Backend: GET /api/admin/share-kit/:seasonId (admin.ts) does one season query + one recent-posts query — no fan-out from the frontend, no N+1 — plus POST /api/admin/share-kit/:seasonId/community-post (super-admin) which generates + caches the r/audiodrama draft on demand. Page: /dashboard/admin/share-kit/[id]/page.tsx (client component, super-admin gated, auth redirect to sign-in). Linked from SuperAdminPanel admin header as 📣 Share kit ↗ next to 🎙 Studio / ? Help / All seasons. What's NOT in v1: KDP cover preview (novella-mode only, separate concern), per-show analytics chart (existing /dashboard/seasons/[id]/analytics covers it), email/Discord share buttons (manual copy works for low volume).

    • EmberKiln invite email + public /studio/new upload route — the two missing pieces between "admin grants creator" and "creator actually uploads a book." New transactional email in studio-notifications.tssendStudioGrantEmail({ email, name?, current_manuscript? }) fires the moment the grant route flips is_creator=true, telling the new creator they're in + linking them to /studio/new (upload), /studio/welcome (90-second tour), and /login. Personalized greeting (uses the signup's name first-name when available) + personalized manuscript line (echoes the manuscript title they typed in the beta form back at them — "We pulled up 'Feathers of Betrayal' from your application…" — so the email feels human, not templated). HTML + plain-text bodies, both ready for Resend's standard send shape; reuses the existing safeResendSend deliverability wrapper so synthetic recipients get dropped at the boundary. Best-effort send: failure logs and falls through, the grant has already landed in the DB so the user has access regardless ("we'd rather have an unwelcomed creator with access than a blocked grant"). The grant route's response now carries { email_sent, email_skipped?, email_error? } so the admin UI surfaces what happened ("✓ Granted… Welcome email sent." vs "✓ Granted… (RESEND_API_KEY missing — email not sent.)"). New public route /studio/new (apps/frontend/src/app/studio/new/page.tsx) mirrors /dashboard/admin/manuscripts/new for granted creators — gives them a Studio-branded URL instead of /dashboard/admin/.... Both routes mount the same client component, extracted to components/manuscripts/ManuscriptUploadClient.tsx with a new optional seasonRouteFor prop (defaults to /dashboard/admin/seasons/<id> — when a Studio-branded /studio/seasons/<id> page ships, /studio/new can override the redirect target without changing the backend or the upload flow). Backend authorizes both routes via the existing requireSuperAdmin middleware which already accepts is_creator — no new gate needed.

    • EmberKiln creator home dashboard (/studio/dashboard) — completes the post-grant landing experience: granted creators who sign in now have a real Studio home instead of bouncing through /dashboard (the journal home) to find their audiobooks. New page /studio/dashboard (client component, requires sign-in, gracefully renders an empty state for non-creators who land on the URL) shows: greeting + + New manuscript CTA, color-coded monthly TTS usage chip (emerald < 70% / amber 70-90% / rose > 90% — with "Need a higher cap?" link to /studio/help when in the red), grid of the caller's seasons with cover thumbnail + title + status pill derived from chapter+audio+ACX state (SetupDraftingRenderingAudio readyExportingPublished) + render-progress bar. Empty state points to /studio/new + the 90-second tour + the FAQ. Backend studio.ts adds GET /api/studio/dashboard (authenticated; owner-scoped — caller sees their own work only). Returns { seasons: [{ id, title, mode, poster_url, created_at, chapters_total, chapters_with_prose, chapters_with_audio, acx_export_status, acx_export_url }], usage } where usage is the existing getMonthlyUsageForUser result (used / limit / remaining / pct_used / resets_at_oldest_drop — same numbers the quota preflight enforces, so the chip matches what they'd see if they tried a render that overflowed). Single IN (...) query for chapter aggregates across all seasons in one round-trip (no N+1). In-product links: StudioFooter (in StudioChrome.tsx) adds Your audiobooks link between Studio and Pricing; SuperAdminPanel admin header gets a 🎙 Studio ↗ link next to ? Help + All seasons ↗ (admins still have access to the all-seasons admin view; creators land in Studio); welcome email's primary CTA flips from "Upload your manuscript" to "Open Studio" pointing at /studio/dashboard, with the upload link demoted to a secondary line — gives returning creators the right landing without forcing them straight to upload before they have a manuscript ready. Season cards link to /dashboard/admin/seasons/[id] for now (where the audiobook-suite tools live); when a Studio-branded /studio/seasons/[id] ships, swap the href without backend changes.

    • Per-show Bluesky chapter-drop auto-post — Bluesky is the only social channel where we can post programmatically without OAuth + app review (cafe-bluesky.ts already uses BSKY_HANDLE + BSKY_APP_PASSWORD to auto-post contest bento + winners). Migration 293 adds story_seasons.bluesky_autopost_enabled (opt-in per show, default false) + story_episodes.bluesky_post_uri + bluesky_posted_at. Service season-bluesky.ts postChapterDropToBluesky({ season_id, episode_number, force? }) reads show title + episode title + poster, composes a post (🎙 New: <Title> — Chapter N — <Episode title>\n\n<graphene.fm/seasons/:id#chapter-N>\n\n#audiodrama #podcast #tiktokbook #serialfiction), embeds the poster as an image, posts via the now-exported bskyPost helper from cafe-bluesky.ts, and stamps bluesky_post_uri for idempotency (re-fire is a no-op unless force: true). Auto-fires fire-and-forget on the success path of POST /:id/trigger-episode when the show has opted in — so every chapter trigger drops a free post into Bluesky for compounding distribution. Routes: PATCH /:id/bluesky-autopost body { enabled: bool } (per-show toggle), POST /:id/episodes/:n/post-to-bluesky body { force?: bool } (manual trigger for back-fill / redo). UI: new collapsed-by-default BlueskyAutopostPanel on /seasons/[id] (super-admin only), sibling to PaidTrafficUrlPanel. Why opt-in per show: Sandon may want to hand-craft the FIRST chapter post per series and flip auto-on for chapter 2+; auto-on by default would unilaterally start posting on existing published shows. Safety gates: refuses to post when is_published_to_graphene=false (chapter link would 404) or episode.triggered=false (chapter isn't visible to listeners yet — the manual /post-to-bluesky route was the unsafe surface; auto-fire is safe because it runs AFTER triggerEpisode flips the column). What's NOT in v1: video embed (Bluesky's video support is uneven across clients; image of poster works everywhere), reply-thread mode (single post per chapter is the default for podcast accounts), cross-post to Twitter/X (API is paid + locked down), Reddit (per-subreddit rules + manual mod review make auto-post a self-ban risk). Engagement scraping (added 2026-05-24 follow-up): migration 294 adds bluesky_likes + bluesky_reposts + bluesky_replies + bluesky_engagement_checked_at on story_episodes. New getBskyPostEngagement(uri) helper in cafe-bluesky.ts calls app.bsky.feed.getPostThread and returns { likes, reposts, replies }; refreshChapterEngagement(episodeId) and refreshAllRecentBlueskyEngagement() live in season-bluesky.ts. Sweep strategy: posts <7d old + not checked in 3h get refreshed, older posts get refreshed if not checked in 7d, bounded at 50/sweep so worst-case wall time stays under a minute. New 4h cron bluesky_engagement_sweep_tick in index.ts (heartbeat-tracked, CRON_LABELS entry added). Always stamps checked_at on every attempt so a deleted/404'd post doesn't get re-polled every sweep. UI: BlueskyAutopostPanel renders a "Recent posts" table when expanded (lazy-fetches via GET /api/story-seasons/:id/bluesky-engagement, max 20 rows) — each row shows the chapter title + ♥/↻/💬 counts + a bsky.app deep link. Lets Sandon see whether autopost actually drives engagement so he can decide whether to keep it on per show.

    • Per-show press kit page + QR code — public, print-optimized one-pager designed for sharing with podcast reviewers, audiobook critics, indie book bloggers, and college/coffee-shop/sticker-maker promo. Page: /seasons/[id]/press-kit/page.tsx — Server Component, public read (no auth), gated by the per-:id ownership middleware so truly-private shows 404 while unlisted + listed shows both render (the URL is meant to be shared with a reviewer without first flipping the catalog toggle). Letter/A4-friendly card: poster + QR on left, title/genre/universe/premise/cast/press contact on right. @page { size: letter; margin: 0.5in; } + @media print strips on-screen-only chrome (header buttons, surrounding gradient) so window.print() produces a clean one-pager. Server-rendered inline SVG QR via the qrcode npm package (errorCorrectionLevel M, margin 2, width 240, dark #0f0a1f on white) — print-quality stays sharp at any size, no client JS needed. Standalone QR endpoint GET /api/story-seasons/:id/qr-code.svg (routes/story-seasons.ts) serves a 512px-wide SVG (same QR shape, sticker-printer DPI sweet spot) with Content-Type: image/svg+xml + 1-day CDN cache. Both the page's embedded QR and the standalone download point at ${grapheneOrigin()}/seasons/<id>/read?karaoke=1#chapter-1 — same destination as a catalog poster click, so scans land on hero parallax + chapter 1 immediately. Discovered via a compact amber "Press kit & QR" panel on /seasons/[id] (owner/admin gated, sibling to YouTubeDescriptionPanel) with "📰 Press kit page" + "⬇ QR code (SVG)" buttons. Why owner/admin-gated entry but public output: the page is meant to be share-with-a-reviewer surface — the owner curates by sharing the URL, not by gating who can view it. What's NOT in v1: merch / print-on-demand integration (Printful/Printify, payment, customization) — explicitly deferred as a bigger project, the SVG QR is the bridge (drop into Figma/Illustrator/Canva to mock t-shirts and mugs).

    • EmberKiln render-complete emails — creator gets pinged when a long-running render finishes so they don't have to refresh the page. Two hooks: bulk chapter audio (POST /:id/render-all-chapters, 30s-15min) → 🎧 "<title>" — audio is ready; ACX/M4B export (POST /:id/acx-export, 30s-5min) → 📦 "<title>" — M4B is ready for ACX. Helper sendStudioRenderCompleteEmail takes { season_id, kind, detail, succeeded }; resolves the season → owner_user_idprofiles.email. Multiple safety bands: missing RESEND_API_KEY skip, platform-owned shows (no owner) skip, owner with no email skip, failure path skip (failures stay in the runs feed, not inboxes), Resend send error logs but never throws. Fire-and-forget from each route's tracker.complete() success path — bad email never disturbs the render pipeline. Email body uses the same tracker detail string (e.g. "Rendered 12/12 · 3.4hrs audio" or "M4B ready · 198 min · 142 MB") so the creator sees the outcome inline without needing to open the app. CTA button deep-links to /seasons/<id> for audiobook completion, since that's where the player + M4B download button live (SuperAdminPanel). Why fan out from tracker.complete instead of a dedicated event bus: only two callers need this today and adding an event bus for two callers is premature. If a third lifecycle email lands (e.g. trailer ready), add inline; if a fourth, extract.

    • EmberKiln support surface (/studio/help + contact form + Discord placeholder) — closes the last open Phase 1 punch-list item: a front door for bug reports / questions / feedback / billing inquiries that surfaces consistently from every Studio page + from inside the SuperAdminPanel. New public page /studio/help (server shell + StudioHelpClient.tsx): hero, two channel cards (email always shown; Discord shown when NEXT_PUBLIC_STUDIO_DISCORD_URL is configured — hides cleanly when unset so we don't ship a broken link before the server exists), 10-item FAQ (waitlist timing, pricing TBD, ACX compliance + AI-narration disclosure, rights requirements, voice-cloning policy, render times, troubleshooting bad renders via the 🎧 preview / 🗣 lexicon / 🎭 voices triage path, monthly char cap rationale, mid-beta continuity, fallback to form), and a contact form (email + name + topic enum [bug/question/feedback/billing/other] + message text). Honeypot-protected; min/max length validation on the message. Backend studio-notifications.ts adds sendStudioSupportEmail({ from_email, from_name?, message, topic, user_id? }) — sends to sandon@hivejournal.com with the topic as a [Studio · bug] / [Studio · question] subject prefix for inbox filtering, reply_to set to the submitter so the human can hit reply and the response routes back automatically. When the submitter was signed in, surfaces the HiveJournal profile link in the email body for fast lookup of spend / quota / season context. Route POST /api/studio/support-request (studio.ts) — uses maybeAuthenticateUser so signed-in users get their user_id attached but anonymous evaluators can still submit. Light validation: email shape + 254-char cap, 5-5000 char message, topic enum coercion. No DB row, no admin queue — Phase 1 closed-beta volume routes through email; queue table + Intercom/Linear handoff land if/when support volume justifies it. In-product links: StudioFooter (in StudioChrome.tsx) gains a Help link between For creators and Terms so every public Studio page surfaces it; SuperAdminPanel gets a subtle ? Help link in the admin header row next to All seasons ↗; the welcome email (sendStudioGrantEmail) now adds step 4 "Stuck? Open the FAQ" with the /studio/help URL. Skipped for v1: ticket queue table + admin UI (volume doesn't justify it yet), Intercom / Linear / Discord webhook integrations (Phase 2 when volume scales), in-product chat widget. Customer support surface was the last open Phase 1 punch-list item per docs/product/EMBERKILN_ROLLOUT.md.

    • EmberKiln pricing + Terms + Privacy pages + acceptance gates (Phase 1 polish) — completes the public Studio surface with three new pages and the legal-shape acceptance flow Phase 1 needs before opening to real beta authors. New public routes (all server components, sharing the new StudioChrome header + footer): /studio/pricing (three-tier framing: Closed beta = free now / Phase 2 revenue share / Phase 2 flat per-book — TBD-marked since Phase 1 cost data is still being collected; "what you're not paying for" comparison vs subscription/narrator/studio); /studio/terms (Phase 1 closed-beta ToS covering author rights covenant, distribution-platform compliance passthrough, ElevenLabs/OpenAI/Anthropic ToS passthrough, voice-cloning policy [no real-person voice cloning during Phase 1], beta revocation, no-warranties + liability cap, NM governing law); /studio/privacy (data collected: account/manuscript/audio/telemetry, third-party processors, audio file URL caveat [unguessable but unsigned], 30-day post-revocation retention, access/deletion rights via email). Both legal pages explicitly disclaim being attorney-reviewed — Phase 1 closed-beta good-faith documents, slated for proper review before Phase 2 paid launch. Acceptance gates: beta signup form (StudioLandingClient.tsx) now requires a TOS+Privacy+rights-covenant checkbox before submit ("I agree to the Terms and Privacy Policy. I confirm that any manuscript I upload is mine or I hold the audiobook rights."); ManuscriptUploadClient.tsx gains an optional requireRightsConfirmation prop that adds a per-book rights-attestation checkbox just above the submit row — turned ON for /studio/new (creator self-attests per book), OFF for /dashboard/admin/manuscripts/new (admin dogfood manuscripts don't need self-attestation). Shared chrome StudioChrome.tsx exports StudioHeader (brand + Pricing / Listen / Join the beta nav), StudioFooter (Studio / Pricing / For creators / Terms / Privacy / Listen links), and StudioShell (single-call wrapper for bookend pages). Landing page now uses StudioFooter for consistency; landing page header stays custom (its page-local scroll-to-#beta CTA). Per-user TOS tracking + version-acceptance audit intentionally skipped for v1 — signup row's created_at acts as de facto acceptance timestamp for Phase 1 closed beta. When paid pricing arrives in Phase 2, add a profiles.studio_tos_accepted_at + studio_tos_version for proper audit trail. No migration in this PR.

    • EmberKiln per-user TTS quota enforcement (Phase 2 unblock) — hard rolling-30-day character cap per creator, enforced via a preflight check in ttsSegment that throws TtsQuotaExceededError if the call would push the user over their limit. Route handlers catch + translate to 429 with a structured { error, code: 'tts_quota_exceeded', used, limit, attempted } body; modals render a friendly "🚦 Monthly TTS limit reached" panel with usage bar + rolling-window explanation. Schema migration 289 — adds profiles.tts_monthly_char_limit (nullable int, NULL = unlimited). Backfill: super_admins → NULL (unlimited), is_creator users → 500_000, everyone else → 0 (no audiobook access by design — requireSuperAdmin denies them anyway, quota is belt-and-suspenders). 500k chars/month ≈ 10 full chapters at $0.10-0.30 each ≈ $10-30/month of TTS exposure per beta user — generous for Phase 1, tight enough that a stuck workflow doesn't burn $500. Service tts-spend.ts adds getMonthlyUsageForUser(userId) (rolling 30-day char count + limit + remaining + pct + resets_at_oldest_drop), checkQuotaForUser(userId, attemptedChars) (super_admin fast path skips spend query; throws TtsQuotaExceededError when over), getSeasonOwnerUserId(seasonId) (resolve owner so admin running a preview "as a favor" still counts against the creator's quota — correct billing semantic), maybeRespondQuotaExceeded(err, res) (Express helper for the structured 429 response). DEFAULT_CREATOR_MONTHLY_CHAR_LIMIT = 500_000 constant mirrors the migration backfill. Attribution wiring: every audiobook-suite TTS callsite now fetches the season's owner_user_id once at function entry and threads it into the context.user_id for each ttsSegment call — renderChapterAudio, renderSeasonAudio, renderIntentPulse (now takes ownerUserId as a parameter), generatePreviewAudio (resolves via getSeasonOwnerUserId helper). Cost + quota always attribute to the season owner regardless of who triggered the render. Grant integration: the POST /api/studio/beta-signups/:id/grant-creator route (studio.ts) now also stamps tts_monthly_char_limit = DEFAULT_CREATOR_MONTHLY_CHAR_LIMIT when the user doesn't already have a custom limit — so future beta grants land with a concrete cap rather than relying on the helper's fallback. UI PreflightAudioModal detects 429 + structured code: 'tts_quota_exceeded' in the response body, renders QuotaExceededPanel with usage bar (used / limit) + rolling-window explanation + "reply to your welcome email for a higher cap" hint. RenderAllChaptersModal gets quota handling for free: the bulk render's per-chapter isolation catches the QuotaExceededError, stamps chapter_audio_last_error with the human-readable quota message, and the existing per-row failed-pill rendering surfaces it inline. Skipped for v1: per-user-monthly usage chip on the AudiobookOverviewCard (per-season chip already exists; cross-season per-user chip needs the overview endpoint to know who's asking — straightforward follow-up), admin UI to change per-user quotas (SQL works for now; admin.ts:role-grants/by-email could extend later), per-tier quotas (everyone gets the same cap in Phase 1; tier-based limits when Phase 2 pricing lands), end-of-month email reminders, quota-warning toast at 80% used. Update (#270 + #269): DEFAULT_CREATOR_MONTHLY_CHAR_LIMIT was lowered from 500_000 → 20_000 in the self-sustaining pricing rework (the $5/mo base now includes ~3–4 chapters; real production spills into the repriced credit packs). And a new platform-writer tier was added — is_platform_writer persona accounts (writer-fleet-owned shows) resolve to PLATFORM_WRITER_TTS_MONTHLY_CHARS (env, default 50_000) instead of 0, so fleet audio renders bounded. Resolution order in getMonthlyUsageForUser: super_admin (null/unlimited) → explicit tts_monthly_char_limit → is_creator (20k) → platform-writer (50k) → else 0. A limit === 0 now throws a self-diagnosing "no quota configured" error (not the cryptic 0/0), and the real ElevenLabs plan headroom is surfaced in /dashboard/admin/system-health (the /v1/user probe reads character_count/character_limit → yellow <20% remaining / red exhausted).

    • EmberKiln beta-signup admin + one-click Creator grant (/dashboard/admin/studio-signups) — completes the Phase 1 rollout loop: public signups land at /studio, super-admins triage + advance status + grant Creator access in one click from this page. New backend route POST /api/studio/beta-signups/:id/grant-creator (in studio.ts) looks up the user by the signup's email via the same paginated auth.admin.listUsers pattern as admin.ts:role-grants/by-email, upserts profiles.is_creator=true, and advances the signup to status='onboarded' with onboarded_at stamped (also back-fills invited_at if the signup was still in pending/reviewed — granting implies invitation). Returns a structured 404 with signup_email when no user matches (signup email doesn't have a HiveJournal account yet) so the admin UI can surface the "share a signup link first" hint instead of a generic error. Why this IS the ungate: the existing requireSuperAdmin middleware on every audiobook-suite route already accepts is_creator users (see story-seasons.ts:120 — the gate is broader than the name suggests; mirrored inline in studio.ts). Flipping is_creator=true immediately unlocks every audiobook route — manuscript ingest, lexicon, character voices, audio preview, bulk render, ACX export, the overview card — without any further middleware changes. UI studio-signups page: status filter bar (pending/reviewed/invited/onboarded/live/declined/archived + all) with per-status counts, per-signup card showing email + qualifying answers (monthly volume, publish target, current method, manuscript freetext, notes) + relative timestamps, context-sensitive action buttons (Mark reviewed → Mark invited → 🎁 Grant Creator → Decline / Archive). The Grant Creator button is the only emphasized (solid emerald) action — it's the moment that actually unlocks suite access. Confirmation dialog before grant; user-not-found case surfaces the "share signup link first" hint inline. Sister page to the existing /dashboard/admin/all-roles (full role/capability matrix) — this page is scoped specifically to the Studio funnel. Next in Phase 2: quota enforcement (per-user monthly character limits using tts_call_log), transactional invite emails so granting a beta sends the user a "you're in" message + signup link automatically, pricing page, public /studio/new for accepted users to bypass the existing super-admin-gated /dashboard/admin/manuscripts/new.

    • EmberKiln public landing + beta signup funnel (/studio) — Phase 1 rollout per docs/product/EMBERKILN_ROLLOUT.md. New public marketing surface at /studio (server-shell + StudioLandingClient.tsx for the interactive form) presents the Audiobook Creator Suite to prospective indie authors and captures interest for the closed beta (5-10 author spots). Six-feature grid + 5-step workflow strip + qualifying form (email required; name + current manuscript + monthly volume + publish target + current method + free-form notes all optional — "rather have a signup with just an email than no signup"). Honeypot field (hp_website, off-screen positioning rather than display:none to slip past bots that detect display:none) silently 200s without persisting when populated. Source attribution from utm_source / ?source= / document.referrer. Sibling to /studio/welcome (90-second tour for already-granted creators) — /studio is for visitors who DON'T yet have access. Schema (migration 288): studio_beta_signups keyed on email (unique — re-submitters update their record), status lifecycle (pendingreviewedinvitedonboardedlive; failed: declined / archived) + invited_at / onboarded_at auto-stamped on status transitions. RLS: public INSERT (form submissions work without auth; route layer enforces shape) + super-admin SELECT/UPDATE/DELETE. Routes in new studio.ts: POST /api/studio/beta-signup (public, upserts on email), GET /api/studio/beta-signups?status=pending (super-admin list with filter), PATCH /api/studio/beta-signups/:id (super-admin status/notes update; auto-stamps invited_at / onboarded_at on relevant transitions). Light validation: email shape + 254-char cap, enum coercion on volume/target/method (anything not matching the allowed sets nulls out rather than poisoning the row). Route mounted at /api/studio in apps/backend/src/index.ts. Next in Phase 2: ungate audiobook routes to a paid-user tier, quota enforcement using tts_call_log spend data, public /studio/new manuscript upload page for accepted beta users.

    • TTS spend tracking — per-call logging + per-season rollup (EmberKiln Phase 2 prerequisite) — the highest-leverage unblock for paid launch per docs/product/EMBERKILN_ROLLOUT.md: every ttsSegment call now logs { season_id, episode_number, voice_id, voice_direction, model_id, character_count, cost_cents, latency_ms, feature_key, user_id } to a new tts_call_log table (migration 286) so we can finally price audiobooks, enforce quotas, and surface cost-per-book to creators. Previously only LLM costs were tracked (via llm_call_log); ElevenLabs TTS — the bulk of audiobook production cost — was untracked. Service tts-spend.tslogTtsCall(input) (best-effort, swallows failures so telemetry never breaks a render) + getSeasonSpend(seasonId) aggregator + TTS_COST_CENTS_PER_KCHAR constant (20 ¢/k, middle of ElevenLabs Starter/Creator/Pro/Scale tier pricing as of 2026-05; update when ElevenLabs raises rates — existing rows keep their snapshot cost_cents). ttsSegment(text, voiceId, direction, context?) gained an optional 4th context param ({ season_id?, episode_number?, feature_key?, user_id? }) — additive so existing callers keep working without modification; audiobook-suite callsites pass context (chapter render → 'audiobook.chapter_render', preview → 'audiobook.preview', intent-pulse → 'audiobook.intent_pulse', season render → 'audiobook.season_render', audio takes → 'audiobook.audio_take'). The log insert fires via dynamic import + void IIFE so a telemetry path failure can never delay the audio buffer returning to the caller. Non-audiobook callsites (cafe stories, lovio seals, timeline audio, short stories) log with season_id=null — still rolls up into global cost dashboards, doesn't pollute per-season aggregates. RLS: super-admin only for reads (writes only via service-role bypass — no INSERT policy by design, prevents log tampering even if RLS-bypass tokens leak). Three indexes: (season_id) where season_id is not null for per-season rollups, (created_at desc) for time-window dashboards, (user_id, created_at desc) where user_id is not null for the Phase 2 per-user billing reads. AudiobookOverviewCard integration: the existing GET /:id/audiobook-overview endpoint now returns a spend: { total_cost_cents, total_character_count, call_count, by_feature: [{ feature_key, cost_cents, character_count, call_count }] } field; the card renders a green TTS: $X.XX chip in the stat row with a hover tooltip showing per-feature breakdown (chapter_render vs preview vs intent_pulse). Failure-isolated: if migration 286 isn't applied yet (e.g. on a stale env) the spend aggregate returns zeros instead of crashing the overview card. Next (still gated on this for Phase 2): per-user quota enforcement based on monthly character counts; pricing-page math from real seasons; the public /studio/new ungate.

    • 🎙 Audiobook status overview card (audiobook-creator-suite Phase 1) — single-glance "where am I in the pipeline" consolidation card at the top of SuperAdminPanel (novel mode only). After 6 production features shipped (preview, auto-cast, auto-suggest, bulk-render, ACX, lexicon), the creator's state was scattered across 4 modals + the stage strip; the overview card pulls it into one read-only summary so creators know the current pipeline state without opening anything. Service audiobook-overview.tsgetAudiobookOverview(seasonId) fans out parallel queries across story_seasons, story_episodes, season_story_bibles, pronunciation_lexicons, season_character_voices (single round-trip from the frontend's POV; one Promise.all on the backend). Returns aggregate stats (chapters total/triggered/with_prose/with_audio/total_words/total_audio_duration; bible character/setting/motif counts; lexicon entry count; voice assigned vs unassigned-active count; ACX export status + url + size) plus a 6-step pipeline progress strip (manuscript → bible → lexicon → voices → render → export) where each step is tagged ready (✓ emerald), in_progress (… amber), or todo (○ gray) with a one-line detail. Route GET /api/story-seasons/:id/audiobook-overview (super-admin). UI AudiobookOverviewCard.tsx renders cover thumbnail + title + narrator voice + readiness ratio chip (N/6 ready), the 6-step pipeline strip with state glyphs + per-step detail tooltip, and a row of stat chips (chapters/words/characters/voices cast/lexicon/audio duration). Refreshes on mount + when refreshKey increments (SuperAdminPanel passes its runsRefreshKey so the card updates whenever a stage action lands). Read-only — no actions live here; the action buttons stay in the tools row below. The card's job is "tell me where I am" not "let me do something new." Cheap query: one parallel fan-out, no LLM calls, no extra cost.

    • 🎬 Bulk chapter render with live progress modal (audiobook-creator-suite Phase 1) — pairs with the auto-cast voices + auto-suggest lexicon + pre-flight preview trio as the "and now do it for real" button. Walks every triggered chapter with prose and renders each one sequentially via the existing renderChapterAudio pipeline; per-chapter isolation means a single failed chapter doesn't abort the batch. Service audio-bulk-render.tsbulkRenderChapters(seasonId, { only_unrendered?, include_untriggered? }) + listChapterRenderStatus(seasonId). Status derivation reads from the durable chapter_audio_* columns (migration 228chapter_audio_url, chapter_audio_rendered_at, chapter_audio_last_attempt_at, chapter_audio_last_error); no in-memory progress map, no schema additions, survives server restarts mid-render. Four states: rendered (has audio_url AND no newer last_error), rendering (last_attempt_at within 5 min AND no audio yet AND no error — the 5-min ceiling means stuck renders auto-flip to pending so admins aren't blocked by a forever spinner), failed (last_error is set AND no successful render after it), pending (everything else). Sequential rendering — not parallel — because ElevenLabs free + paid tiers rate-limit by concurrent requests; 12 chapters in parallel trips 429s. Cost: ~$0.10-0.30 per chapter; visible in the modal so admins can ballpark before kicking off a bulk render. Two routes on /api/story-seasons (super-admin): POST /:id/render-all-chapters (fire-and-forget — body { only_unrendered?: boolean, include_untriggered?: boolean }; returns 202 immediately since renders take 30s-15min, way past any proxy timeout; bound to pipeline_events stage 'audio' for the runs-feed dashboard) and GET /:id/render-all-chapters/status (polled every 4s by the modal — returns per-chapter { episode_number, title, triggered, has_prose, word_count, status, audio_url, duration_seconds, rendered_at, last_attempt_at, last_error }). UI RenderAllChaptersModal.tsx opened via 🎬 Render all button in SuperAdminPanel (novel mode only, between 🎧 Preview audio and 📦 Export M4B). Summary chips at top (eligible / rendered / rendering / failed / pending counts + total duration). Action row with "Force re-render all" checkbox (confirms before overwriting cached audio — expensive in TTS credits) + primary "Render N unrendered chapters" button. Per-chapter list shows ch + title + status pill (color-coded: emerald rendered / sky animate-pulse rendering / rose failed / gray pending) + word count + duration + relative "rendered Xm ago" timestamp. Failed chapters show the inline error text + a per-row ↻ Retry button that fires the existing POST /:id/episodes/:n/render-audio route with force: true. Mobile-aware scroll. While the batch is active, the modal polls every 4s; auto-stops polling when no chapter is in rendering state (the batch completed). Why a discrete bulk render route exists alongside renderSeasonAudio (the stage-4 button): the legacy path has an "abort everything if the first segment fails" guard which is the wrong semantic for novel-mode bulk renders — we want per-chapter isolation. The frontend also wants live per-chapter progress, which the all-or-nothing legacy path can't surface.

    • Auto-suggest pronunciation lexicon — LLM scan of bible + prose for proper nouns (audiobook-creator-suite Phase 1) — counterpart to auto-cast voices: same "AI does the tedious setup, you review" pattern, but for the lexicon. The #1 lexicon friction point is creators only discover mispronunciations by listening — typically after the first audio render burns real ElevenLabs credits — and then have to manually transcribe IPA/respell for each. Auto-suggest fronts that loop by gathering candidates from the bible (characters' names + voice_brief context, settings, capitalized motifs) and prose-scanning every chapter for proper nouns (regex + stopword filter + frequency-ranked), then asking the LLM to propose { ipa, respell, rationale } per term. Defaults to claude-haiku-4-5 at temperature 0.2 — ~0.5-2¢ per scan. Service lexicon-autosuggest.tsautoSuggestLexicon(seasonId, { prose_scan_limit?, model_key? }). Candidate sourcing in priority order: (1) bible characters with voice_brief/physical/backstory context attached so the LLM can pick a culturally-grounded pronunciation; (2) bible settings + sensory_palette/rules; (3) bible motifs (only when their image_or_phrase looks like a proper noun, not a phrase); (4) prose scan top-N by frequency, capped at prose_scan_limit (default 40, max 100). Pre-filters: excludes terms already in the lexicon (case-insensitive dedupe), strips sentence-leading stopwords (The, And, day/month names, common titles like Dr/Mrs), enforces min 3 chars. LLM-side filter: terms common English readers handle correctly (John, London, etc.) are returned in a skipped_common array surfaced to the admin as a count, not as suggestions. Sibling bulkAddLexiconEntries accepts the admin-edited subset; per-row failures don't abort the batch — failed[] reports collisions/malformed-IPA inline. Routes POST /api/story-seasons/:id/pronunciation-lexicon/auto-suggest (super-admin) — returns { suggestions: [{ term, ipa, respell, source, occurrences, rationale }], skipped_already_in_lexicon, skipped_common, model_key, cost_cents, latency_ms }. POST /api/story-seasons/:id/pronunciation-lexicon/bulk-add — body { entries: [{ term, ipa?, respell?, notes?, case_sensitive? }] }, returns { created, failed }. UI: new ✨ Suggest entries button next to + Add entry in PronunciationLexiconModal.tsx. Click → spinner → suggestions panel appears with a checkbox per row (all selected by default), editable IPA + respell fields (pre-filled from LLM proposal, mutable inline), source-tagged chip (emerald=character / sky=setting / fuchsia=motif / amber=prose), occurrence count, rationale italic. "Select all" / "Deselect all" toggle. "Add selected" sends the (possibly edited) subset to bulk-add; new entries appear in the main lexicon list below + a success toast shows Added N entries. Same workflow as auto-cast voices: fast LLM scan → review → accept → preview audio via 🎧 to validate any uncertain pronunciations.

    • Auto-cast character voices — LLM bulk picker (audiobook-creator-suite Phase 1) — single biggest friction point post-manuscript-ingest is "now pick a voice for each of these 12+ characters." Auto-cast turns that 10-minute click-fest into a 15-second LLM call: reads the bible's active characters (name, age, mbti, voice_brief, physical, inner_life, arc, verbal_tics) + the full ElevenLabs VOICE_BANK with gender/age/accent/mood metadata + valid VOICE_DIRECTIONS preset keys, asks the model to match each character to a voice with a one-sentence rationale grounded in their bible entry. Defaults to claude-haiku-4-5 (low-creativity classification task) at temperature 0.2 — ~0.5-2¢ per cast. Service character-voice-autocast.tsautoCastCharacterVoices(seasonId, { replace_existing?, model_key? }). Defensive validation: filters out hallucinated voice IDs (LLM may pick a voice that isn't in the catalog → reported as unassignable, never persisted); falls back to default direction if the picked preset isn't a real key; tells the LLM about voices already in use by un-recast characters so it doesn't duplicate; surfaces characters the LLM skipped or marked unassignable. Persists via existing upsertCharacterVoice so cache invalidation + (season_id, character_key) uniqueness are handled in one place. Returns { assignments: [{ character_key, character_name, voice_id, voice_name, voice_direction, rationale, is_new }], unassignable: [{ character_key, character_name, reason }], skipped_existing, model_key, cost_cents, latency_ms }. Route POST /api/story-seasons/:id/character-voices/auto-cast (super-admin) — body { replace_existing?: boolean, model_key?: string }. Two semantics: replace_existing=false (default — fills only unassigned characters, untouches existing picks) and replace_existing=true (re-casts everything, frontend confirms first). UI: new amber toolbar at the top of CharacterVoicesModal.tsx with two buttons — ✨ Fill unassigned (safe default) and Re-cast all (destructive, browser confirm before firing). Post-cast summary panel shows per-character rationales (collapsible), color-coded badges (green "new" / amber "re-cast"), and an "Couldn't cast" callout for unassignables. Workflow loop: auto-cast → preview audio (🎧 button, sibling feature) on chapter 1 → tweak any voice that sounds wrong → preview again. Costs are typically 0.5-2¢ per cast (4-12k input tokens depending on character count + voice catalog stays constant); cost surfaces in the summary panel so admins see what each cast cost.

    • Pre-flight audio preview — fast iteration loop for voice/lexicon settings (audiobook-creator-suite Phase 1) — render a short test clip from any chapter using the season's current narrator voice + pronunciation lexicon + character-voice assignments WITHOUT touching storage or stamping any cached audio. Closes the iteration gap between "edit lexicon entry" and "hear it" that used to require a full ~10-30s chapter render + ElevenLabs credits + Storage upload for every tweak. Service audio-preview.ts reuses the exact production pipeline pieces — applyLexiconToTextsplitProseByVoice → parallel ttsSegment per span → concatMp3Buffers — so the preview accurately reflects what the full render will sound like; the only difference is no upload and no DB writes. Truncates source prose to the first N words (default 150, clamped to 20-400) on a clean word boundary; when N exceeds the chapter's word count the entire chapter renders. Routes on /api/story-seasons (super-admin): GET /:id/preview-audio/chapters (lightweight { episode_number, title, has_prose, word_count }[] for the modal's dropdown) and POST /:id/preview-audio with body { episode_number?, max_words? } — returns audio/mpeg binary with metadata in headers (X-Preview-Episode, X-Preview-Chapter, X-Preview-Words-Used, X-Preview-Words-Total, X-Preview-Truncated, X-Preview-Spans — JSON-encoded span breakdown of voice_id/voice_direction/character_key/char_count for the UI legend). When no episode is specified the route picks the first chapter with prose so the preview button never silently errors on a half-drafted season. UI: PreflightAudioModal.tsx — chapter dropdown (empty chapters disabled), word-count preset pills (50/100/150/200/300), render button with inline spinner, inline <audio controls autoPlay> element fed from a blob URL (revoked on next render or modal close so we don't leak ~50-300KB per preview), collapsible voice breakdown showing which voice rendered which span (color-coded: emerald chips for character spans, gray for narrator). Mobile-aware scroll. Opened via 🎧 Preview audio button in SuperAdminPanel tools row between 🎭 Voices and 📦 Export M4B — sits right next to the settings that drive what the preview demonstrates so the iteration loop is one panel. Costs only a few hundred characters of ElevenLabs billing per pass (vs ~5,000-30,000 for a full chapter), so creators can iterate dozens of times without thinking about cost. What's NOT in v1: cached previews (each click renders fresh — the whole point is to hear edits immediately), split preview (just one span at a time), voice A/B comparison (creator runs two previews back-to-back), or preview download (use the player's right-click → "save audio as" if you want a local copy).

    • Kindle .epub export — Phase 2 monetization sibling to ACX/M4B — every Graphene novella now exports as a KDP-uploadable EPUB 3 file alongside the existing .m4b. Phase 2 of the monetization plan (GRAPHENE_MONETIZATION.md) per the original sequencing: "ship Option C (Kindle + Audible) for the 6 best novellas, build credibility for the SaaS pitch." Service season-kindle-epub.tsgenerateSeasonKindleEpub(seasonId) builds an in-memory EPUB 3 zip (mimetype STORED-first per spec, META-INF/container.xml, OEBPS/content.opf/nav.xhtml/cover.xhtml/title.xhtml/chapters/chapter-N.xhtml/colophon.xhtml, styles/book.css). Cover from story_seasons.poster_url (JPEG/PNG only; webp + others silently skipped — KDP rejects). Author from profiles.full_name of the season owner, falling back to "Graphene Author" so we never ship a missing dc:creator (KDP-rejecting). Stable UUID = the season id so re-exports keep the same Kindle identifier (KDP can update in-place vs creating a new ASIN). Body conversion mirrors cafe-best-of-epub.ts: blank-line-separated paragraphs become <p>, soft newlines within a paragraph become <br/>. Approx word count + chapter count surfaced via response headers (X-Kindle-Chapters, X-Kindle-Word-Count) so the admin UI can show what landed. Route GET /api/story-seasons/:id/kindle-export (super-admin gated) — synchronous, returns the .epub binary with Content-Disposition: attachment; filename="<slug>.epub". Synchronous is fine: the build is in-memory, no LLM calls, no TTS, no Storage upload; even a 12-chapter 60k-word novella zips in <1s. UI: new 📚 Export Kindle .epub ActionButton in SuperAdminPanel tools row next to 📦 Export M4B (novel mode only). Click handler does an authenticated fetch + blob-URL download trick since the route is super-admin gated (can't use a plain anchor href). What's NOT in v1: per-tier metadata (categories, series, BISAC codes — picked manually in KDP's editor by the author), cover-image auto-generation for KDP's 1600x2560 spec (we ship whatever poster_url is, author uploads a different cover via KDP if needed), .mobi conversion (KDP does it server-side since 2024), AI-narration disclosure in EPUB (lives in the Audible/ACX submission, not the print Kindle file), per-show metadata customization (the colophon is uniform).

    • Print-on-demand export — print-ready 6×9 PDF (Phase 0) — turns a novel-mode season into a print-ready 6×9 interior PDF the author uploads to Lulu / KDP / IngramSpark themselves (no POD API / commerce / fulfillment yet — Phase 0 de-risks the hard part: a PDF that passes preflight). The "Print" form of EmberKiln's one-story-every-form pitch. Service print-export.tsgenerateSeasonPrintPdf(seasonId) mirrors the Kindle epub's chapter fetch (story_episodes triggered + non-empty chapter_prose, author from profiles.full_name), builds a typeset book interior via the pure, unit-tested buildPrintBookHtml() (title page + copyright page + per-chapter sections, justified serif body, chapter page-breaks, first-paragraph flush-left), and renders via new renderBookPdf() in persona-browser/pool.ts — drives trim (6×9) + margins + a centered page-number footer entirely through Playwright's page.pdf() options (vs renderHtmlToPdf's paper-format path). Route GET /api/story-seasons/:id/print-export (super-admin gated), synchronous, returns the PDF binary with X-Print-Chapters/X-Print-Word-Count headers. UI: 🖨️ Export Print PDF (6×9) ActionButton in SuperAdminPanel next to the Kindle/M4B exports (novel mode; same auth-gated blob-download trick). Golden test tests/print-export.test.ts locks the HTML builder + paragraph escaping. Phase 1 (Lulu Print API — in-app ordering): services/lulu.ts wraps OAuth2 client-credentials (cached token) + cost calculation + print-job creation + status, gated by isLuluConfigured() (env LULU_CLIENT_KEY/LULU_CLIENT_SECRET/LULU_API_BASE/LULU_CONTACT_EMAIL; defaults to the free sandbox). generateSeasonPrintAssets() in print-export.ts renders the interior, counts its pages via countPdfPages() (pdf-parse), derives the spine (spineWidthInches() — 444 PPI for the 6×9 BW package 0600X0900BWSTDPB060UW444MXX), and renders a full-wrap cover (buildCoverHtml() — EmberKiln teal lockup, back+spine+front). Routes routes/print-orders.ts (creator-gated, mounted /api/print-orders): POST /:seasonId/cost (live price), POST /:seasonId/order (render → upload both PDFs to the public season-assets bucket via uploadPdf() → create Lulu job → persist), GET / + GET /:orderId (status syncs from Lulu on open). Migration 419 print_orders tracks each order + Lulu job id + status + cost/address snapshots. UI: 📦 Order print copies button → PrintOrderModal.tsx (address + qty → live price → place order). Setup: LULU_PRINT_API_SETUP.md. Golden tests cover the spine math + cover builder. ⚠️ Verify the spine constant against Lulu's sandbox cover template before production. Phase 1.x (status tracking — shipped): order status stays fresh without opening each order — (1) print_orders_sync cron (every 15min, services/print-order-sync.ts syncInFlightPrintOrders() polls open orders' Lulu jobs; heartbeat + cron-toggle + label; no-op when Lulu unconfigured); (2) Lulu webhook POST /api/print-orders/webhook (raw-body HMAC via LULU_WEBHOOK_SECRET, mounted before global express.json() like the Stripe webhook; verifyLuluWebhook/extractWebhookJob in lulu.ts); (3) admin dashboard /dashboard/admin/print-orders (lists orders + status + a "↻ Sync now" button → POST /api/print-orders/sync). Phase 2 (reader storefront — shipped): a reader buys a printed copy of a PUBLIC novel from the reading page (📖 Order the paperback → PaperbackStorefront.tsx on ReadClient). services/print-storefront.ts: quoteRetail() (pure margin math — PRINT_MARGIN_PERCENT/PRINT_MARGIN_MIN_CENTS, rounded up to 50¢; unit-tested), assertPublicNovelSeason() guard, createReaderPrintCheckout() (inserts a pending_payment row → Stripe Checkout session), fulfillReaderPrintOrder() (renders + places the Lulu job on payment, idempotent; on failure → paid_unfulfilled + critical error). Routes POST /api/print-orders/:seasonId/retail-quote + /checkout (authed reader, not creator-gated). Fulfillment rides the existing Stripe webhook (routes/payment.ts kind:'print_order' branch → checkout.session.completed). Migration 420 adds kind/stripe_checkout_session_id/retail_amount/recipient_email/paid_at. What's NOT yet: guest (logged-out) checkout, Lulu wallet/payment automation, trim sizes beyond 6×9 BW paperback, a cover designer, Paged.js front-matter polish. Notebook → printed journal (Phase 0): the same 6×9 interior pipeline, but for a journaling notebookgenerateNotebookPrintPdf(notebookId) in print-export.ts fetches the notebook's journal_entries (oldest→newest) and maps one entry → one chapter via the pure, deps-free, unit-tested notebookEntriesToPrintChapters() in notebook-prose.ts (graphic-novel entries render their panels to prose via entryToChapterProse, dropped when that's empty so raw JSON never prints; text/outline entries verbatim; chapter title = entry title or its date). The prose helpers were extracted from routes/notebooks.ts (shared with port-to-graphene). Route GET /api/notebooks/:id/print-export (owner-gated) streams the PDF. UI: an owner-only 🖨 Print-ready PDF button in the notebook header on /dashboard/notebooks/[id] (binary fetch + blob download). Golden test tests/notebook-prose.test.ts. Phase 1b (Lulu ordering for notebooks — shipped): the same interior+cover→Lulu path as seasons, but owner-gated (any user prints their OWN notebook, not just creators). generateNotebookPrintAssets(notebookId) renders the interior + a full-wrap cover — buildCoverHtml() now takes an optional lineage so a printed journal reads "A HiveJournal journal" instead of the EmberKiln studio line. Routes on /api/print-orders (authed, ownership-checked): POST /notebook/:notebookId/cost + POST /notebook/:notebookId/order; print_orders now allows a notebook_id in place of season_id (migration 439 — nullable season_id, new notebook_id, CHECK exactly-one; the admin GET / list joins notebooks(name)). UI: a 📦 Order printed copy button beside the Print-ready PDF one, opening the (now generic — takes an endpointBase) PrintOrderModal.tsx. Golden test covers the cover-lineage param. Not yet for notebooks: a reader storefront (season-only — a notebook is private by nature) and cover-image/trim customization.

    • Per-season theme — mood + accent (season-theme.ts + SeasonThemeModal.tsx) — the season page + reader were one hardcoded dark-purple look for every show (drab + samey). Adds an opt-in theme: a background mood preset (void/loch/ember/mist/noir — each a base color + a viewport gradient) + an accent color. Migration 430 adds story_seasons.theme_mood + theme_accent. resolveSeasonTheme() returns isThemed:false for the default so unthemed seasons are pixel-identical (no regression); when themed it paints the mood gradient as the page root's own background (background-attachment: fixed, so it covers the viewport behind the starfield without the opaque-cover/stacking problem a separate fixed layer would hit) and exposes --ek-accent. Applied in SeasonClient + the reader (root bg + the chapter eyebrow / genre chip accent; other purple chrome left alone for v1). Owner/super-admin picker: 🎨 Theme button in the poster controls column → mood swatches + an accent color input (mood default or custom) → PATCH /:id/theme {mood, accent} (validates mood enum + #RRGGBB); the parent re-themes live from the returned state. Distinct from visual_style_override/style_reference_image_url (those steer image generation, not the page chrome).

    • Movie-poster overlay — style a cover as a movie poster (poster-compose.ts + poster-tagline.ts + MoviePosterStudio.tsx) — an owner styles their season's cover into a movie poster: a template lays the title, an LLM tagline (generatePosterTagline, claude-haiku-4-5 from the premise), and optionally the author's pen name over the art (a dedicated profiles.pen_name, migration 429, defaulting to full_name; editable inline in the studio — which persists it to the profile — and from /dashboard/settings via PUT /api/user/profile). Composed server-side (HTML → PNG via the shared Chromium pool's new renderHtmlToPng), so the on-screen preview equals the printed product. Typography leverages the Maxell ad font catalog (MAXELL_FONT_CATALOG — Inter/Anton/Bebas Neue/Special Elite/Crimson Text) via @font-face against the same CDN (Chrome loads the web fonts; no disk download like the ffmpeg path). 3 templates (classic/spotlight/minimal), fixed 13:19 ratio (the merch poster aspect). The cover is inlined as a data URI so the render never waits on a remote image. Migration 428 adds poster_template/poster_tagline/poster_show_credit/poster_show_on_page/poster_overlay_url/poster_overlay_built_at to story_seasons. Routes (owner/super-admin gated by the router.param('id') guard): POST /:id/poster-tagline (generate), POST /:id/poster-preview (compose → data URL, ephemeral), PUT /:id/poster-overlay (save settings + compose+store the display-res composite, or clear when template null). The 🎬 Movie poster button sits under the poster on the season page (next to Make it real); a modal gives a live (debounced) WYSIWYG preview + template picker + tagline generate/edit + pen-name toggle + a "use as page poster" toggle. When that's on, the season page hero shows poster_overlay_url instead of the bare cover. Regenerating the cover invalidates the cached overlay (nulls poster_overlay_url/_built_at + clears poster_show_on_page, keeping template+tagline for a one-click re-save), alongside the merch moderation reset in season-media.ts. Print integration (shipped): fulfillMerchOrder (merch.ts resolvePrintAsset) composes the overlay over the upscaled cover at print resolution (POSTER_PRINT_WIDTH_PX=2600, ~200 DPI at 13×19) for a poster product whose season has a template — so the printed poster matches what was styled; apparel/drinkware keep the bare cover, and composition failure falls back to the bare upscaled cover (never blocks fulfillment). Per-chapter posters: the same studio is reused per chapter from the season-hub chapter-posters strip (AdminSeasonHub.tsx — a 🎬 Movie poster button under each chapter tile). MoviePosterStudio takes an optional episodeNumber; when set it targets /api/story-seasons/:id/episodes/:n/{poster-tagline,poster-preview,poster-overlay} (same generic contract, only the base path differs), composing over story_episodes.chapter_cover_image_url and persisting to the chapter_poster_* columns (migration 431) — non-destructive (the reference cover is preserved). The chapter tagline is written from the chapter's own prose (falls back to the season premise); the styled overlay surfaces in place of the reference thumbnail in the strip once saved. The season-only "use as page poster" toggle is hidden in chapter mode. Getting the poster out: the studio has ⬇ Download + 📋 Copy (clipboard) actions — composed-poster PNG to a file or the clipboard for pasting into a social composer — via the shared image-clipboard.ts util (cross-origin fetch-to-blob + Safari-safe ClipboardItem promise). The same util powers a 🎬 Poster · share image card in the share kit that surfaces the styled poster_overlay_url (falling back to the bare cover with a "style one" link) with Download/Copy — so the marketing kit includes the ready-to-post poster. The share-kit endpoint (admin.ts GET /api/admin/share-kit/:seasonId) now selects poster_overlay_url + poster_template.

    • Story-as-merch — print-on-demand posters (Phase 0) — "a story as all forms" made physical: an author orders a 13×19″ poster of their story's cover art, printed + shipped by Gelato (a second POD provider that coexists with Lulu — Lulu prints books only, no posters/apparel). Phase 0 is author-at-cost self-orders from the season page, framed as the inspiration poster (pin it up, write beneath it). The one genuinely new step is the print-resolution path (print-asset.ts ensurePrintAsset()): screen covers are ~1024² (~7× short of 300 DPI for a poster), so it upscales the existing cover 4× via Replicate Real-ESRGAN and caches it to season-assets/print/<kind>-<id>-4x.png (once per cover, not per order; gated on REPLICATE_API_TOKEN). gelato-client.ts wraps Gelato Order API v4 (isGelatoConfigured/createGelatoOrder/getGelatoOrder), inert without GELATO_API_KEY. merch.ts: Phase-0 product catalog (code constant), quoteSelfMerch() (at-cost: base + shipping, margin 0), createSelfMerchCheckout() (Stripe Checkout mode:'payment' collects the shipping address), fulfillMerchOrder() (webhook → upscale → place the Gelato order, or park at awaiting_fulfillment when Gelato is unconfigured; on failure → paid_unfulfilled + critical error, mirroring print-storefront). Routes routes/merch.ts (mounted /api/merch): GET /products (catalog + at-cost prices), POST /:seasonId/self-checkout (owner-gated). Fulfillment rides the existing Stripe webhook (routes/payment.ts new kind:'merch_order' branch). Migration 426 merch_orders (image-shaped, modeled on print_orders) + nullable creator_revenue_splits.merch_creator_pct (Phase-1 payout split). UI: owner-only MakeItRealCTA.tsx (🖼️ Make it real → Order a poster) rendered directly under the poster image (poster-width, in the same column as the regenerate/upload overlay) so the on-screen poster and the orderable physical print read as one unit — only when a real generated poster_url exists (not the placeholder source panel). Setup: GELATO_MERCH.md. Spec + phasing: EMBERKILN_STORY_AS_MERCH.md. Phase 1 (fan storefront — shipped): any visitor (guests allowed) can buy a poster of a PUBLIC story to support its author. Migration 427 adds 'merch_revenue' to the creator_earnings.source_kind CHECK + moderation-cache columns on story_seasons. Pure money math in merch-pricing.ts (computeFanQuote — base + shipping + max(base×MERCH_MARGIN_PERCENT, MERCH_MARGIN_MIN_CENTS) rounded to 50¢; computeMerchSplit — creator share of the margin), golden-tested (merch-pricing.test.ts, 9 cases). Pre-sale moderation merch-moderation.ts ensureMerchSellable() (gpt-4o-mini vision: rejects recognizable real faces / brand logos / trademarked chars; verdict cached on the season; fails open on infra error). merch.ts adds assertPublicSeason (gate on is_publicly_viewable), getSeasonStorefront, createFanMerchCheckout (guests via soft auth, moderation-gated, 422 merch_cover_rejected on block), and bookMerchEarning inside fulfillMerchOrdercreator_earnings row (source_kind='merch_revenue', status pending, split via merch_creator_pct ?? MERCH_DEFAULT_CREATOR_PCT=50; at-cost self-orders no-op). Routes GET /:seasonId/storefront (public) + POST /:seasonId/checkout (soft auth). Fulfillment cron merch-order-sync.ts syncInFlightMerchOrders() (the merch_orders_sync cron, 15min, heartbeat + toggle, no-op without Gelato). UI FanPosterStorefront.tsx — public "Support this story" card shown to non-owner visitors; self-hides when not sellable. Env: MERCH_MARGIN_PERCENT(40)/MERCH_MARGIN_MIN_CENTS(500)/MERCH_DEFAULT_CREATOR_PCT(50). Phase 1 PR B (apparel — shipped): MerchProduct gains a kind (poster|apparel|drinkware) and MERCH_PRODUCTS adds tee / hoodie / mug / cap (env-overridable Gelato UIDs + costs: GELATO_{TEE,HOODIE,MUG,HAT}_*), fulfilled by the same Gelato client (no Printful). Checkout/moderation/payout/cron are all product-agnostic — pure catalog + UI work; the cover is reused as the print file as-is (Gelato fits it per product). Both storefront components (FanPosterStorefront.tsx + the owner at-cost MakeItRealCTA.tsx) became product pickers. Sell-gate: every merch sell path (both checkouts + the product/storefront list endpoints) gates on isGelatoConfigured() (checkouts 503, lists empty/404 → CTAs self-hide) so we never take money we can't fulfill; awaiting_fulfillment parking only covers a transient Gelato outage after it's wired. Marketing (positioned as the next "every form" output): a Merch chip in EmberkilnFlow (/emberkiln), a /features entry, and a /studio FAQ — safe ahead of activation because the buy paths self-gate. What's NOT yet (Phase 2): Stripe Connect transfer automation (earnings book as pending; payout rides the existing reconciliation path), live Gelato price/shipping quoting (catalog is still a code constant), designed multi-element posters off the IR, per-garment print-file treatment, per-author SKU customization. Poster-offer drip (day-1 "make it real", Phase 0): poster-offer-drip.ts — ~24h after someone starts a story (a story_seasons row aged 20–72h) that has a cover (poster_url), email the owner an at-cost poster offer (the "put your cover on your wall" excitement moment), CTA → their season page where the owner-only Make-it-real order lives. Mirrors welcome-drip.ts (15-min cron, Resend safeResendSend, 50/tick cap) and reuses the welcome-drip marketing opt-out (welcome_emails_optout + the same HMAC /api/welcome/unsubscribe route — one marketing opt-out, not two). Idempotent per season via story_seasons.poster_offer_sent_at (migration 432), stamped only on a successful send (transient Resend failures retry within the window; opted-out / no-email owners are skipped and age out). No-op without RESEND_API_KEY or when Gelato is unconfigured; OFF by default (env POSTER_OFFER_DRIP_ENABLED + the poster_offer_drip cron toggle) since it sends real marketing email. Cron: poster_offer_drip in CRON_REGISTRY + poster_offer_drip_tick heartbeat. Phase 1 (abandoned win-back — shipped): a second step in the same tick (runWinbackStep) emails the owner of an unpublished story that's gone quiet — is_published_to_graphene=false, ≥14 days old, and whose latest chapter (or, if none, its creation) is >10 days ago — a "don't let it fade, pin the cover up, finish it" re-engagement + poster offer. Separate idempotency stamp poster_winback_sent_at (migration 433) so a story can get both. Never emails the autonomous fleet (skips is_platform_writer owners). Gated by its OWN flag POSTER_WINBACK_ENABLED (default off) so day-1 can run first before the win-back goes over the backlog.

    • ACX/M4B export — distribution-ready single-file output (audiobook-creator-suite Phase 1) — turns the platform output from "MP3 stream on /seasons/[id]" into "single .m4b file the creator can upload to ACX (Audible), Findaway Voices, Apple Books, or Google Play Books." Closes the loop from suite-input (manuscript ingest) to suite-output (audiobook file ready for the world). Migration 284 adds acx_export_url, acx_export_rendered_at, acx_export_status (idle/rendering/failed), acx_export_error, acx_export_duration_seconds, acx_export_size_bytes on story_seasons. Render pipeline acx-export.ts: pulls all triggered chapters in order with their existing chapter_audio_urls → downloads each MP3 to a temp dir → loudness-normalizes via ffmpeg's loudnorm=I=-20:TP=-3:LRA=11 (middle of ACX's -23 to -18 LUFS window, -3dB true peak ceiling) → builds a ;FFMETADATA1 chapter-marker file with TIMEBASE=1/1000 and cumulative START/END offsets per chapter → single ffmpeg invocation concatenates via the concat demuxer, re-encodes to AAC LC 128kbps mono 44.1kHz, embeds chapter markers + title/artist/album metadata + cover art (poster_url pulled as attached_pic if present) → uploads to Supabase Storage at acx/<season_id>.m4b (content-type audio/mp4). Temp dir cleaned up in a finally block even on failure. Routes POST /api/story-seasons/:id/acx-export (fire-and-forget — sets status='rendering' + clears prior error, kicks off void background render, returns 202; render duration is 30s-5min depending on book length, well past any proxy timeout) and GET /:id/acx-export (status poll — returns { url, rendered_at, status, error, duration_seconds, size_bytes }). Failed renders write to service_errors so the daily /errors triage flow + Marlow's Sunday ops report surface them. UI AcxExportModal.tsx opened via 📦 Export M4B button in SuperAdminPanel tools row — three states (idle / rendering with 4s poll / ready with download link + duration/size hint + re-render). Mobile-aware scroll. About-the-format disclosure lists the spec choices so admins know what they're getting. What's NOT in v1: per-chapter MP3 zip (ACX accepts both single-M4B and per-chapter-MP3 uploads — v1 ships the consumer-friendlier M4B), two-pass loudnorm (single-pass lands within ACX tolerance for ~99% of TTS material since ElevenLabs is already consistently leveled), auto-upload to platforms (each has its own API workflow — manual download + upload is the v1 contract), pre-flight validation report.

    • Manuscript ingestion — cover-image auto-import (audiobook-creator-suite Phase 1.6) — manuscript uploads now also auto-extract the cover image from EPUB and DOCX files and set it as the new season's poster_url on commit. Closes the "manuscript → fully-set-up season" workflow loop: drop an EPUB → preview the chapters AND the cover → click create → land on a season page with the poster already attached. EPUB cover detection (in epub-parser.ts) priority: (1) EPUB 3 manifest item with properties containing cover-image; (2) EPUB 2 <meta name="cover" content="<id>"/> in metadata block → resolve the id in the manifest; (3) EPUB 2 last-resort manifest item with id="cover" and media-type starting image/. DOCX cover detection (in docx-parser.ts) reads docProps/thumbnail.jpeg (or .jpg / .png fallbacks) — Word auto-generates this on save when "Save Thumbnail" is on (default in recent versions). PDF cover uses pdf-parse v2's getImage({ partial: [1], imageThreshold: 200 }) to extract embedded images from page 1, then picks the largest by width × height — that's the cover for fiction PDFs that ship it as an embedded image (the common case for self-published / Reedsy exports). PDFs that compose their cover from vector primitives still surface as no-cover; canvas rasterization deferred to a future V2 since @napi-rs/canvas is a heavy dep for the small affected slice. Cover bytes flow as cover_image_base64 + cover_image_mime through EpubParseResult → /parse response → preview UI → /create-season body → new uploadCoverImage(prefix, buffer, mime) helper (sibling to uploadImage, MIME-aware so JPEG covers don't get labeled as PNG) → Supabase Storage → poster_url set inline on the season insert. UI: preview stage of /dashboard/admin/manuscripts/new shows the cover thumbnail next to the title/author/language metadata in a flex layout with an inline "Drop cover" affordance — admin can scrap the auto-import (e.g. cover is a stock-image placeholder) and either upload manually later or trigger the existing generate-poster flow. When the manuscript ships no cover, the metadata grid shows a "Cover: None embedded — auto-generate or upload later" hint. Upload is failure-isolated: a cover-upload error logs + falls through to season-without-poster (admin can attach one later via the regenerate-poster flow). Size cap at 10MB to keep the JSON request payload reasonable.

    • Manuscript ingestion — PDF support (audiobook-creator-suite Phase 1.5) — adds .pdf to the manuscript-ingest trifecta alongside EPUB + DOCX. New pdf-parser.ts uses pdf-parse v2 (which wraps pdfjs-dist) for text extraction; chapter detection is regex-based on common header patterns since most self-published PDFs don't set bookmarks (Chapter N, CHAPTER N, Chapter One, Chapter I Roman numerals, Part N, standalone Prologue/Epilogue/Interlude). When the header line is bare ("Chapter 1" on its own), the parser peeks ahead 1-3 lines for a short non-terminal-punctuated line and promotes it to the chapter title. Falls back to single-chapter when no headers detected. Reflow logic (reflow()) handles PDF text-extraction artifacts: drops pure-numeric short lines (page numbers), "Page N" / "N of N" patterns, common ligature glyphs (fi → fi, fl → fl, ffi → ffi); collapses intra-paragraph wraps (single newlines inside a paragraph from layout-engine line breaks) while preserving paragraph breaks (double newlines + lines that end with sentence-terminal punctuation). Front-matter prose before the first chapter boundary is dropped unless substantial (>200 chars); when substantial it gets stamped as "Front matter" so the admin can choose to drop it in the preview UI. Route dispatcher in manuscript.ts extends from 2-way (.epub/.docx) to 3-way (.epub/.docx/.pdf); same EpubParseResult shape so the create-season flow stays format-agnostic. UI accept-attr + copy widened ("Drop an EPUB, .docx, or .pdf here"). New dependency: pdf-parse@2.4.5 + @types/pdf-parse. Failure modes throw clean errors (image-only/scanned PDFs surface "needs OCR" message instead of returning empty content). What's NOT covered: bookmark/outline-based chapter detection via pdfjs-dist.getOutline() (V2 if pattern-based detection proves insufficient), running-header detection + removal, footnote handling, table extraction, embedded font remapping.

    • Manuscript ingestion — EPUB + Word/.docx upload → season (audiobook-creator-suite Phase 1) — bring-your-own-book pathway. Lets a creator upload an existing .epub or .docx (Word, Scrivener export, Google Docs export, etc.) and turns it into a Graphene season + episodes + synthesized live takes (same shape as the post-fab5f8e5 Odessa-to-Graphene port). DOCX-specific docx-parser.ts is a sibling to the EPUB parser — same pure-Node approach with adm-zip (DOCX is also a zip; Office Open XML). Reads word/document.xml for body, docProps/core.xml for metadata (title/author/language). Chapter detection (in priority order): (1) <w:pStyle w:val="Heading1"/> paragraphs — Word's "Heading 1" outline style, the highest-signal split for authors who use Word's outliner; (2) <w:br w:type="page"/> page breaks — common in manuscripts that skip heading styles; (3) single-chapter fallback when neither signal exists. The dispatcher in manuscript.ts routes by file extension — .docx → parseDocx, otherwise (.epub or unspecified) → parseEpub. Both parsers return the same EpubParseResult shape so the downstream UI + create-season flow stays format-agnostic. Without this, the platform only works for creators willing to LLM-generate prose; with it, any author with an EPUB can take their book through the existing TTS + multi-voice + karaoke + chapter-playlist + listener-resume pipeline. Two-step flow on /api/manuscript (super-admin in v1; creator-dashboard widens it in v2): POST /parse accepts { filename, content_base64 } (max 25MB decoded — route-mounted express.json({ limit: '25mb' }) so the global 100kb cap stays untouched for every other endpoint), returns { title, author, language, chapters: [{ order, title, prose, char_count }] }. No DB writes — admin previews + edits chapter titles in-memory + can drop front-matter pages before commit. POST /create-season accepts the edited chapter list + season metadata (title, premise, optional genre/setting), creates the season (draft, novel mode, bounded narrative, owner=caller), inserts episodes with chapter_prose pre-populated + triggered=true, and synthesizes one live take per chapter stamped platform_version='ported_v1' (same sentinel notebooks.ts:2c uses post-fab5f8e5). Parser epub-parser.ts — pure-Node, no new dependency. Uses already-present adm-zip to unzip, reads META-INF/container.xml to locate the OPF manifest, parses OPF metadata + manifest items + spine with targeted regex (the XML subset EPUB uses is well-specified enough to skip a full xml2js dependency). For each spine itemref, pulls the chapter file, extracts the title (first <title>/<h1-3>), converts XHTML body to plain text via a hand-rolled stripper (drops <head>/<script>/<style>/<h1-6>, converts block closings to paragraph breaks, decodes HTML entities). Strips chapter headings out of the prose body so the chapter-playlist player's "Chapter N" announcement doesn't get duplicated by the TTS reading "Chapter Twelve" on top. Throws on malformed EPUBs (missing container, missing OPF, empty spine) so the route surfaces clean errors. UI at /dashboard/admin/manuscripts/new — three-stage state machine on one route: upload (drop-zone + file picker, .epub only) → preview/edit (chapter list with editable titles + drop-row buttons + collapsible prose preview + season-metadata form prefilled from EPUB metadata) → submit (spinner + redirect to /dashboard/admin/seasons/[season_id] on success). Mobile-aware scroll (max-h-[60vh] + touch-pan-y + overscroll-contain). V2 candidates: Word/.docx + PDF support, cover-image extraction, per-chapter voice picker, pre-flight audio preview before commit.

    • Per-season pronunciation lexicon — first-class fix for the #1 LLM-TTS rage: mispronounced character / place / made-up-word names. Migration 283_pronunciation_lexicons.sql adds pronunciation_lexicons keyed by (season_id, term) with ipa text, respell text, notes text, case_sensitive boolean, and a CHECK constraint requiring at least one of ipa / respell (empty entries can't accumulate). Super-admin RLS matches the bible / takes / critic pattern. Service pronunciation-lexicon.ts — CRUD helpers + applyLexiconToText(text, seasonId) with a 60s in-memory per-season cache (so a 30-segment chapter render does 1 DB query, not 30). applyLexiconSync extracts the substitution logic for unit-testing independent of the cache: longer terms applied first ("Auditor Pell" wins over "Pell"), word-boundary anchoring for single-word terms, case-insensitive by default. IPA mode wraps matches in <phoneme alphabet="ipa" ph="..."> SSML tags (ElevenLabs auto-detects inline SSML in the text payload — no separate flag); respell mode does a plain literal swap. Wired as the LAST step before ttsSegment fires at every TTS callsite in the season audio pipeline: season-novel-chapter-audio.ts (chapter audio render), season-audio.ts renderSeasonAudio (segment render), AND renderIntentPulse (multi-voice Breakout-framework chorale — each voice fragment passes through applyLexiconToText before its individual TTS call). Full coverage means a character/place/word mispronunciation can only happen if a lexicon entry is missing, never because a code path forgot to apply it. Routes on /api/story-seasons: GET /:id/pronunciation-lexicon (list), POST /:id/pronunciation-lexicon (create), PUT /pronunciation-lexicon/:entryId (update), DELETE /pronunciation-lexicon/:entryId. All super-admin gated via requireSuperAdmin. UI: PronunciationLexiconModal.tsx — sorted list with inline add form, inline edit (row expands into the same form shape), delete with confirm. Mobile-friendly (max-h-[calc(100dvh-4rem)] + touch-pan-y + overscroll-contain for iOS Safari). Opened via 🗣 Pronunciation button in SuperAdminPanel tools row next to 📖 Bible (every season mode — pronunciation overrides matter for any TTS work, not just novels). Next audio render picks up edits within 60s; bypass the cache earlier by forcing a re-render.

    • Chapter critiques (PR5 of story-bible-and-chapter-takes plan) — parallel infrastructure to season-level season-critiques.ts, at the take level. Migration 119_chapter_take_critiques.sql adds episode_take_critiques (findings as jsonb with paragraph_index instead of segment_position) + episode_critique_decisions (composite finding_id = "{critique_id}:{idx}" with action apply | ignore | override, tracks applied_at + applied_to_take_id for the iterate-with-notes loop). Reuses the same critic registry from script-critics.ts — no new critics needed; the existing tension/dialogue/pacing critics work on chapter prose unchanged. Service episode-take-critiques.tsrunChapterCritique (splits prose at blank-line paragraph boundaries, sends to critic, parses + persists findings), listChapterCritiquesForTake, listChapterDecisionsForTake, recordChapterCritiqueDecision, buildChapterCritiqueDecisionBlock (folds pending apply/override decisions into a "CRITIC NOTES ADDRESSING THE PREVIOUS TAKE" prompt block ready to splice into the next chapter take's system prompt), markChapterDecisionsApplied. Iterate-with-notes flow: generateChapterTake accepts apply_critiques_from_take_id — when set, builds the block from that take's pending decisions, threads it through generateChapterRaw → callChapterPromptGPT (which now accepts an optional critiqueBlock slice in the system prompt), and after the new take inserts marks the consumed decisions applied. Routes: POST /episodes/takes/:takeId/critique (body { critic_keys, model_key? }), GET /episodes/takes/:takeId/critiques (returns critiques + decisions), PATCH /episodes/critique-decisions/:findingId (body { take_id, action, admin_text? }). UI: critique panel in ChapterTakesModal — critic chips with multi-select + Run button, per-finding cards (severity-toned border, paragraph index, quote, problem, suggestion, apply/ignore/override buttons, override textarea). "Iterate with N notes" button visible when there are pending decisions — generates a new take with the block applied. "Apply all undecided findings" bulk button when multiple critiques are loaded. Shows applied-to-take pointer once a decision has been consumed.

    • Chapter takes (PR4 of story-bible-and-chapter-takes plan) — direct mirror of the season-level season-takes.ts at the episode level. Migration 118_story_episode_takes.sql adds story_episode_takes (episode_id + season_id FKs, denormalized episode_number, prose text instead of jsonb segments, prompt_snapshot, bible_version_id soft-FK to season_story_bible_versions for take-level provenance) and story_episodes.active_take_id. Status lifecycle mirrors season takes: draft → live | archived | rejected, with a partial unique index ensuring one live take per episode. Backwards compat: migration synthesizes a legacy_v1 live take for every existing novel-mode chapter that has chapter_prose populated, so active_take_id points at a real row everywhere — the takes UI shows "1 take (legacy)" rather than "0 takes" for chapters generated pre-feature. Migration also tightens episode_take_bible_deltas.take_id FK to story_episode_takes(id) (was nullable in PR2; new PR4 deltas saved by chapter takes populate it). Service episode-takes.tsgenerateChapterTake(episodeId, opts) calls generateChapterRaw (extracted from season-novel-chapter.ts) which does NOT persist, then inserts as status='draft' and queues any bible_delta with take_id set. promoteChapterTake(takeId) atomically: archives the prior live take for the episode, flips this take live, writes prose into chapter_prose, sets active_take_id, and rebuilds segments via a local cache-preserving copy of rebuildSegmentsFromCache (duplicated to avoid circular import). rejectChapterTake, archiveChapterTake, listChapterTakes(episodeId) (no prose), listChapterTakesForSeason(seasonId), getChapterTake(takeId) (full prose + prompt_snapshot). Routes mirror season-takes one-for-one: POST /episodes/:episodeId/takes, GET /episodes/:episodeId/takes, GET /:id/episode-takes (season overview), GET /episodes/takes/:takeId, PATCH /episodes/takes/:takeId (promote | reject | archive). UI: new ChapterTakesModal.tsx — list pane left + full prose detail right, generate form at top with framework/model/temperature/optional-label, promote/archive/reject per draft, side-by-side live-take diff view, expandable prompt snapshot. Shared bits extracted into takes-shared.tsx (STATUS_TONE, formatCost, formatDuration, FrameworkOption, ModelOption) — TakesModal was updated to import from there. New 📚 Takes button per triggered chapter in EpisodesAdminModal (novel mode only) shows count badge of non-rejected takes. PR5 will add the chapter-level critique loop. The legacy generateChapter path (used by the existing ✍ Generate prose button) still writes directly to chapter_prose for backwards compat — PR6 will redirect it through the takes layer.

    • Bible backfill + summary rebuild (PR3 of story-bible-and-chapter-takes plan) — two service paths for keeping the bible in sync with prose. backfillBibleFromChapters (in story-bible.ts) reads every triggered chapter's chapter_prose, sends the full text to GPT (lower temp 0.4 — extraction not invention), and produces a fresh bible with bootstrapped_from='retroactive'. Useful for novel-mode seasons that existed before the bible feature shipped — bootstrap-from-plan only sees thin chapter outlines, while backfill reads the actual prose where character voices and details have been locked in. rebuildSummaryFromProse regenerates just summary_so_far from current chapter prose — the safety net for the model-proposed summary drift (Q7 in the plan picked option C: model-proposed verbatim with this rebuild as backup). Routes: POST /:id/bible/backfill (body { force?: boolean }), POST /:id/bible/rebuild-summary. UI: empty-state Bible modal now offers both ✨ Bootstrap from plan AND 📚 Backfill from prose side-by-side; the Current tab's bottom toolbar exposes ↻ Re-bootstrap, 📚 Backfill from prose, and ↻ Rebuild summary for ongoing maintenance. Both backfill and rebuild-summary snapshot the current bible to season_story_bible_versions before writing, so the history timeline preserves every state.

    • Embedded-JSON-envelope fix — stripJsonEnvelopeFromProse extension + migration 287 (2026-05-24) — third in the envelope-leak series (278: trailing suffix, 281: whole-body envelope, 287: embedded mid-prose). New leak class observed at https://www.graphene.fm/seasons/cec79b18-9524-4d12-b8b6-317460a56f30/read?karaoke=1#chapter-1: chapter starts with legitimate prose, several paragraphs render correctly, then a literal { appears on a new line followed by "prose": "..." with \n\n escape sequences visible. Model wrote two takes back-to-back inside one response — first as prose (correct format), second as a fresh JSON envelope (wrong format). The existing detector in stripJsonEnvelopeFromProse only checked prose.startsWith('{') or prose.startsWith(' ```json '), so the leading-prose buffer hid the embedded envelope from every safety net (helper + scanner + migration 281). Code fix in season-novel-script.ts: added findEmbeddedProseEnvelope(prose) helper that scans for any { followed within 120 chars by a "prose": key, then brace-balance-walks forward (tracking string + escape state) to find the matching }, then JSON.parses the substring. When a valid { prose: string } is found mid-stream, splice: keep prose up to {, append the parsed prose value (with \n\n correctly unescaped by JSON.parse), drop the JSON syntax + anything after }. Multiple embedded envelopes in one row handled via a loop. Rescues nested bible_delta as before. DB cleanup migration 287 mirrors 281's 4-table coverage (story_episodes.chapter_prose, story_episode_takes.prose, season_audio_segments.text_content, season_script_takes.segments JSONB) with a plpgsql port of the same brace-balance + parse logic. Idempotent — rows without an embedded envelope are untouched. Scanner coverage: prose-leak-scanner.ts calls stripJsonEnvelopeFromProse directly, so the hourly cron picks up the new shape automatically with no scanner-side change. After applying: re-render audio for any chapter cleaned by 287 — existing season_audio_segments audio was TTSed against the corrupted prose and narrates JSON-envelope nonsense. One-click repair button (added 2026-05-24 follow-up): the hourly prose-leak cron has up to a 1-hour delay before catching a fresh leak; this gap means listeners can see broken prose live. Super-admins now get a 🚩 button in the chapter header on /seasons/[id]/read (next to the existing admin i info badge) that one-clicks POST /api/story-seasons/:id/episodes/:n/repair-prose (story-seasons.ts). The route runs the same analyzeProse helper (now exported from prose-leak-scanner.ts) against story_episodes.chapter_prose + every story_episode_takes.prose for that episode (so a future promote of an older take doesn't reintroduce the leak), then fire-and-forget queues a renderChapterAudio(force=true) under the existing stage='audio' pipeline-event tracker. Returns immediately with { leak_detected, kinds, chapter_prose_updated, takes_updated, audio_rerender_queued, message }. Inline status renders next to the button — emerald confirmation + auto-clear 10s on success, gray "no leak detected" + auto-clear 5s when prose was already clean, rose error otherwise. Confirmation modal lists what's about to happen + the ElevenLabs cost note before firing.

    • JSON-envelope-in-prose fix — stripJsonEnvelopeFromProse + migration 281 (May 2026) — sibling to migration 278 (which handled SUFFIX leakage of bible_delta JSON glued onto the end of prose). 281 handles the ENVELOPE case: the entire prose body IS a markdown-fenced JSON payload — \``json\n{\n "prose": "..."\n}\n```— that the reader sees verbatim on/seasons/[id]/read. **Root cause**: Anthropic's Claude wraps response_format: 'json_object'responses in markdown fences (Anthropic treats the format as a hint; only OpenAI strictly enforces it). The leading backticks madeJSON.parsefail incallChapterPromptGPT→ catch block fell through to "treat whole content as prose" → fenced envelope persisted tostory_episodes.chapter_prose. The existing stripBibleDeltaJsonFromProsesafety net didn't trigger because the response had no"add_characters"/"voice_brief"/ etc. delta keys (just"prose"itself). **Repro**: https://www.graphene.fm/seasons/f051e016-c3d3-40f7-ad73-1f9194d82ab1/read?karaoke=1#chapter-1. **Code fix** (3 parts in [season-novel-chapter.ts](../../apps/backend/src/services/season-novel-chapter.ts) + [season-novel-script.ts](../../apps/backend/src/services/season-novel-script.ts)): (1) pre-strip ```` ```json ```` / ```` ``` ```` markdown fences beforeJSON.parseso the envelope path actually fires; (2) newstripJsonEnvelopeFromProse(prose) helper detects prose that IS an envelope (fenced or bare-JSON-starting-with-{and containing"prose":near the start), parses it, extracts theprosefield, AND rescuesbible_deltaif nested; (3) belt-and-suspenders call tostripJsonEnvelopeFromProsepost-parse incallChapterPromptGPTAND defensively ingenerateChapterProse(the text-mode path — Claude occasionally wraps text responses in JSON envelopes too). **DB cleanup migration**: 281 mirrors 278's 4-table coverage —story_episodes.chapter_prose, story_episode_takes.prose, season_audio_segments.text_content, season_script_takes.segmentsJSONB. Idempotent + safe (only touches rows whose prose actually parses as{"prose": "..."}; rows that fail to parse are untouched). After applying, **re-render audio for any cleaned chapter** — the existing season_audio_segments` audio was TTSed against the corrupted prose and now narrates JSON-envelope nonsense.

    • Bible deltas (PR2 of story-bible-and-chapter-takes plan) — model proposes bible updates ALONGSIDE chapter prose; admin reviews in an approval queue. Migration 117_bible_deltas.sql adds episode_take_bible_deltas (nullable take_id for now — chapter takes land in PR4; required episode_id FK to story_episodes). Status lifecycle: pending → applied | rejected | superseded. Service story-bible-deltas.ts: saveProposedDelta, listDeltasForSeason, approveDelta (applies on top of current bible via applyDelta in story-bible.ts → snapshots a new bible version), approveDeltaWithEdits (admin-modified delta is applied + persisted in place of the original for audit), rejectDelta. The chapter prompt in season-novel-chapter.ts callChapterPromptGPT now switches to response_format: 'json_object' when a bible exists and asks for { prose, bible_delta, notes? } — JSON parse failure falls back to plain-text prose so a JSON glitch never loses the model's chapter. After a successful generate, non-empty deltas are queued (best-effort; failure doesn't block prose persistence). Deltas are additive + update-only — never deletes (deletes happen via direct admin edit in the bible editor). Routes: GET /:id/bible/deltas?status=pending|applied|rejected|superseded|all, PATCH /bible/deltas/:deltaId (body { action, edited?, commit_message?, reason? }). UI: a 🔔 Deltas tab in StoryBibleModal with per-delta cards showing add/update tally chips, the model's optional commentary line, the most useful fields previewed inline (current_state updates, summary refresh), and a collapsible full-JSON view. Approve / Reject buttons per row. Approving applies + bumps the bible version; the modal reloads so the new version is immediately visible in the Current and History tabs. Pending count shows as a badge on the tab.

    • Story Bible (PR1 of story-bible-and-chapter-takes plan) — versioned canon document the chapter prose generator reads BEFORE writing. Migration 116_season_story_bible.sql adds season_story_bibles (one canonical row per season — characters/themes/settings/motifs/planted_threads/voice_rules/tone_rules/do_not_use as JSONB, plus synopsis and rolling summary_so_far) and season_story_bible_versions (append-only history with commit_message per version). RLS is super-admin-only — the bible includes secret_knowledge per character + planted thread setups, both spoiler material. Service story-bible.ts: getCurrentBible, getBibleVersion, listBibleVersions, bootstrapBibleFromPlan (one GPT call against season.premise + setting + episode plan, idempotent unless force), commitBible (snapshots prior version, writes new contents to canonical row, bumps version int), buildBiblePromptBlock (token-budgeted to ~1500 tokens; trims by relevance — drops motifs not yet planted by the current chapter, then status='introduced_only' characters, then themes, then settings). Routes: GET /:id/bible, GET /:id/bible/versions, GET /:id/bible/versions/:version, POST /:id/bible/bootstrap (body { force?: boolean }), PUT /:id/bible (body { contents: Partial<bible-fields>, commit_message: string }). Hooks: generateNovelSeasonPlan in story-seasons.ts auto-bootstraps the bible after season insert (best-effort; failure doesn't block season creation). The chapter prompt in both season-novel-chapter.ts and season-novel-script.ts injects the bible block above the framework directive — empty string when no bible exists, so legacy seasons keep working unchanged. UI: StoryBibleModal.tsx — three tabs (Current read-only view, Editor with JSON-textarea-per-section + commit message, History timeline) plus bootstrap empty state. Opened via 📖 Bible button in SuperAdminPanel tools row (novel mode only). Schema is mode-agnostic — journal-mode seasons can opt in later by exposing the button. PR2 lands the model-proposes-delta loop with admin approval queue.

    • Segment-level TTS cache reuserenderChapterAudio and renderSeasonAudio now skip ElevenLabs entirely for any segment whose audio_url is already set; they download the cached MP3 from storage instead. The cache key is the row itself: season_audio_segments.audio_url is only populated on a successful render of the current text_content + voice_id + voice_direction, and rebuildSegmentsFromCache (in season-novel-chapter.ts) now snapshots the existing (episode_number, segment_type, voice_id, voice_direction, text_content) → audio_url map before the delete/insert and copies the URL onto matching new rows. Net effect: regenerating chapter 6's prose only re-TTSes chapter 6's segments — chapters 1–5 download their cached MP3s back from Supabase storage. Both routes accept { force: true } to bypass the cache; in the admin modal, hold ⇧Shift while clicking the per-chapter audio button to force fresh TTS for every segment in that chapter. The audio render's pipeline-event detail line now reports X TTSed · Y cached so admins can see the cache hit rate at a glance.

  • Episode admin — admins can now control which episode AI personas are currently focused on (independent of triggering), and toggle each episode between draft and public visibility. Migration 109_episode_visibility.sql adds story_episodes.visibility ('draft' | 'public', default 'public') and replaces the public-read RLS policy with triggered = true AND visibility = 'public'. Service helpers in apps/backend/src/services/story-seasons.ts: untriggerEpisode() flips triggered=false and recomputes current_episode to the highest still-triggered episode (or 0); setCurrentEpisode() points the AI personas at any already-triggered episode (the persona-context block reads from this); setEpisodeVisibility() toggles draft/public. Routes: POST /:id/trigger-episode now accepts { episode_number } to target a specific episode (fallback: next episode), POST /:id/untrigger-episode body { episode_number }, PATCH /:id/current-episode body { episode_number }, PATCH /episodes/:episodeId body { visibility: 'draft' | 'public' }. The GET /:id route best-effort-detects super-admin auth and returns drafts only to admins; non-admins see public episodes only. The /timeline-chart public endpoint also filters by visibility = 'public'. UI: EpisodesAdminModal.tsx — per-episode triggered/active/visibility controls, opened from a 📋 Episodes button in SuperAdminPanel that replaced the old ▶ Next Episode button (the modal subsumes the next-episode shortcut). Semantic note: triggered=true, visibility='draft' means AI personas reference the episode in entries AND the script generator includes it (since journal entries already mention it), but listeners don't see it on the season page or the timeline chart. To fully hide an episode, leave it untriggered.
  • Story Engine — Script Takes (multi-take + multi-framework + multi-model). The script-generation step is no longer destructive: each generation lands in season_script_takes (migration 108_season_script_takes.sql) as a draft row; an admin diffs takes side-by-side and promotes one to live. Promotion copies the take's segments into season_audio_segments so audio/music/video pick up the change on the next render. Service apps/backend/src/services/season-takes.tsgenerateTake() calls generateSeasonScript(seasonId, { persist: false }) so the live segments stay untouched until promotion; promoteTake() archives the previous live take, flips status, rewrites season_audio_segments, and updates story_seasons.active_take_id. Frameworks live in apps/backend/src/services/story-frameworks.ts — currently three_act, kishotenketsu, vignette_mosaic, in_medias_res. Each framework defines intensity_curve, vignette_cadence, narrator_role, entry_style, and a prompt_directive that gets concatenated into the script GPT prompt by buildFrameworkPromptBlock(). LLM provider shim in apps/backend/src/services/llm.ts — narrow complete() interface that dispatches to OpenAI (gpt-4o-mini, gpt-4o) or Anthropic (claude-sonnet-4-6, claude-haiku-4-5, claude-opus-4-7) based on model_key; calculates per-call cost in cents using public list prices (refresh manually); auto-logs every call to llm_call_log for /dashboard/admin/llm-spend. Tool calling: LLMCompleteOpts.tools + tool_choice pass through to OpenAI; LLMCompleteResult.tool_calls + finish_reason surface back. LLMMessage supports a 'tool' role + optional tool_calls (assistant turns) / tool_call_id (tool turns) so a multi-turn tool exchange threads through the shim without dropping to the raw SDK. The Anthropic provider path THROWS when tools is set (their schema differs in non-trivial ways — input_schema vs parameters, tool_use vs tool_calls blocks — and the only caller pins an OpenAI model; provider-fallback also skipped when tools is set so a transient OpenAI error doesn't silently misroute to anthropic). Routes: GET /api/story-seasons/frameworks (public — for the picker), GET /api/story-seasons/models (super-admin, includes per-key available flag based on env vars), POST /:id/takes (super-admin, sync — generates one draft take), GET /:id/takes (super-admin, list without segments), GET /takes/:takeId (super-admin, full take + segments), PATCH /takes/:takeId with action: 'promote' | 'reject' | 'archive'. Admin UI: TakesModal.tsx — split view, take list left + segment-by-segment diff right with the live take strikethrough'd under any segment that changed; opened from a 🎭 Takes button next to 👁 Preview in SuperAdminPanel. The existing generateSeasonScript() signature gained an opts: { framework_key?, model_key?, parameters?, persist? } parameter and now returns { ..., segments, framework_key, model_key, parameters_used, cost_cents, latency_ms } — backward-compatible defaults so the existing Generate Script button still works unchanged. New env var: ANTHROPIC_API_KEY (optional — only needed if admins want to pick Claude models in the takes modal).
  • Vertical (9:16) trailer for TikTok / IG Reels / YouTube Shortsmigration 290 adds trailer_vertical_video_url + trailer_vertical_rendered_at + trailer_vertical_duration_seconds on story_seasons. Service season-trailer-vertical.ts wraps the existing horizontal trailer in 1080×1920 branded chrome via ffmpeg filter_complex: dark canvas + horizontal video letterboxed in the middle 1080×608 band + drawtext layers for title (top), "A GRAPHENE SHOW" subtitle, "LISTEN ON GRAPHENE" CTA (bottom), and graphene.fm handle. This is the "podcast-clip aesthetic" — common on TikTok for audio-fiction / podcast content where viewers expect letterboxing; what differentiates good from bad is whether the chrome carries brand identity in the silent-autoplay scroll. Why post-process instead of native 9:16 render: parameterizing VIDEO_W/VIDEO_H + portrait Ken Burns + portrait tag-card across the 638-line season-trailer.ts is 10× more code than wrapping the existing output. Lets us iterate the chrome design independently. Cheap: ~3-8s of ffmpeg compute (single-pass filter_complex with libx264 veryfast). No LLM, no TTS, no per-segment work. Requires the horizontal trailer to exist first — errors with a clean message pointing the admin at the standard 🎞️ Build trailer button. Reuses the same Inter Bold font path via season-video.ts:getFontPath(). Drawtext sanitization stripped same as horizontal (backslash, colon, quotes, brackets). Output is +faststart-muxed so social-platform uploaders can stream-process. Route POST /api/story-seasons/:id/render-vertical-trailer (super-admin, fire-and-forget, 202 with pipeline-event tracking under stage 'trailer'). UI: new 📱 Vertical (TikTok) button in the 'trailer' stage of StageStrip.tsx, disabled until the horizontal trailer exists. Captions (added 2026-05-24 follow-up): trailer audio gets transcribed via OpenAI Whisper (whisper-1, response_format: 'srt', language: 'en') before the ffmpeg pass; resulting SRT is burned into the bottom of the letterbox band via the ffmpeg subtitles filter with force_style overrides for the TikTok-canonical caption look (white text, black outline, drop shadow, Alignment=2, MarginV=740 → lands captions at y≈1180 just under the video). Why captions matter: most TikTok scrolling is muted — without captions the vertical trailer is a static branded-chrome image to anyone who didn't unmute. Captions turn it into "people read along." Cost: $0.006 per trailer (Whisper at $0.006/min, trailers are 30-60s). Graceful: missing OPENAI_API_KEY or transcription failure logs + ships without captions instead of failing the whole render. What's NOT in v1: native portrait render (Ken Burns vertical-framing) — kept post-process for code economy; auto-post to TikTok / IG / YouTube APIs (each requires OAuth + app review — Phase 2); word-level karaoke-style captions (Whisper supports verbose_json with word timestamps + ASS animation could enable this — deferred since sentence-level matches what people are used to); thumbnail / cover-frame extraction (TikTok auto-picks one). Bulk render (added 2026-05-24 follow-up): one-click batch renderer for every published show that has a horizontal trailer but no vertical version yet. Route GET /api/admin/missing-vertical-trailers returns the count + the next batch's titles; POST /api/admin/bulk-render-vertical-trailers renders up to MAX_BATCH=5 sequentially (sequential so ffmpeg CPU spikes don't pile up; per-show failure-isolated so one bad row doesn't block the rest) and returns { processed, total_remaining, errors[] }. Each batch costs ~$0.03 (Whisper × 5) + ~1 min wall time. UI: cyan banner at the top of /dashboard/admin/seasons — only renders when there are missing trailers, shows count + estimated cost, and the button label updates to 🎬 Render next N where N=min(missing,5). Re-clicks drain the queue; the count refreshes from the server after each batch so the banner reflects new state. The goal is to remove the per-show clicking from the post-PR-merge workflow — one click → a deck of TikTok-ready clips for the social schedule. Auto-drafted social captions (added 2026-05-24 follow-up): migration 291 adds social_caption_draft + social_caption_drafted_at on story_seasons. Service social-caption.ts calls gpt-4o-mini (temp 0.8, ~$0.001/call) with title + premise + genre + iTunes ASO copy, returns a TikTok-native caption: punchy hook on line 1, 1-2 follow-up teaser lines, then 6-10 hashtags (lowercase, broad-then-specific, NO #fyp/#foryou — overused + downweighted). System prompt enforces ≤280 chars total + conversational voice + cliffhanger endings (drives comments → drives reach). Auto-fires fire-and-forget on the success path of renderVerticalTrailer so the post copy lands right after the file. Bulk-render response includes the drafted caption per show so the banner can render a copy-button list right after the batch. Routes: GET /api/story-seasons/:id/social-caption (returns current draft + drafted_at), POST /api/story-seasons/:id/social-caption/regenerate (synchronous re-draft when the first attempt isn't a fit). Graceful: missing OPENAI_API_KEY or LLM failure logs + returns null so the trailer-render success path doesn't fail. UI: the bulk banner on /dashboard/admin/seasons now renders a per-show caption card under the success message with a 📋 Copy button per row (1.8s ✓ Copied flash on click).
  • Trailers (Stage 6.5) — 30–60s teasers built by stitching 3–5 of the season's strongest already-rendered audio segments together over the focus persona's leitmotif, plus a portrait Ken Burns track and a final "LISTEN ON GRAPHENE" tag card. Schema in migration 107_season_trailer.sql (adds trailer_audio_url, trailer_video_url, trailer_rendered_at, trailer_beats jsonb, trailer_focus_persona_id, trailer_duration_seconds columns to story_seasons). Service apps/backend/src/services/season-trailer.tsselectTrailerBeats() uses GPT-4o-mini to rank candidate segments (filtered to skip the final 25% to avoid spoilers, capped at 10s per beat); deterministic intensity-based fallback when the LLM is unavailable. pickFocusPersona() chooses whose leitmotif sits under the bed (most-spoken in the picked beats, falling back to first themed cast member). Audio chain: per-beat trim + 0.15s/0.25s afade in/out → concat → append 3.5s tag silence → mix with looped motif under sidechain compression (threshold 0.05, ratio 8). Video chain: per-beat Ken Burns of speaker portrait (or poster fallback) + final tag card with drawtext overlay. No new ElevenLabs / Replicate calls — reuses cached segment MP3s and motif clips, so a render costs only ffmpeg compute (~30–60s wall time). Route: POST /api/story-seasons/:id/render-trailer (super-admin, fire-and-forget, 202 with pipeline-event tracking under stage 'trailer'). Surfaces: TrailerHero.tsx (click-to-play hero above the main player on /seasons/[id]), PosterCard.tsx (muted/looping autoplay on hover for /graphene poster grid — replaces the previous inline <Link> in graphene/page.tsx). The 'trailer' stage is wired into STAGE_INPUTS (audio + music + visuals) so InlineRunsFeed shows version provenance like Trailer v2 (Audio v3; Music v2; Visuals v1). PostHog events for measuring whether the trailer surface earns its keep: graphene_poster_hover (with season_id, had_trailer, dwell_ms — only fires for hovers ≥400ms so the funnel isn't noisy), graphene_poster_clicked (with had_trailer, from_hover — primary conversion lever; compare CTR on cards with vs without a trailer), season_trailer_view (impression on detail page), season_trailer_play (CTA clicked), season_trailer_completed (with watched_ms). All wrapped via trackProductEvent() in apps/frontend/src/lib/analytics.ts.

A session's worth of work fixing why paid ad traffic (FB/IG/YouTube) landed but never converted. Diagnosed via PostHog session replays: 88% of paid traffic is mobile and ~98% bounced off the season page within seconds without clicking anything — a landing-page problem, not a paywall problem (people never reached the paywall). The season page is a destination page where listening means navigating to /read; cold mobile attention doesn't survive that.

  • Cold-ad instant-listen heroColdAdListenHero.tsx renders a prominent one-tap trailer player (reusing TrailerHero.tsx) as the FIRST thing cold-ad visitors see — no navigation, sign-up, or paywall. Gated on isPaidAdVisitor() in analytics.ts (utm_medium=paid / fbclid / gclid / youtube.com referrer, checked against live URL + stored first-touch), so organic visitors get the unchanged page. Rendered on both the season page (SeasonClient.tsx — suppresses the in-page trailer section to avoid a duplicate player) and the homepage (GrapheneClient.tsx — featuring the hero-snippet/freshest season with a trailer, since ~15% of paid traffic lands on /; hides the small HeroAudioSnippet pill for these visitors). A placement prop ('season_page' | 'homepage') tags events + drives the heading level (h1 only where it's the page's main heading) and the surface tag. Measured by cold_ad_listen_hero_shown (impression, carries placement) → season_trailer_play with surface = 'cold_ad_hero' / 'cold_ad_hero_home' (TrailerHero gained a surface prop). PostHog dashboard 1682159 ("Paid acquisition") has the play-rate funnel + trailer-plays-by-surface tiles.
  • First-touch attribution — PostHog runs person_profiles: 'identified_only', so anonymous ad traffic never got $initial_utm_source and signups couldn't attribute back to a campaign. analytics.ts captureFirstTouchAttribution() stashes utm_*/fbclid/gclid/referrer in localStorage on first landing (first-write-wins), and identifyUser() replays them as $set_once person props (first_touch_*) at signup — works despite identified_only, no extra anonymous-profile billing. Synthesizes utm_source=fb from a bare fbclid (organic FB shares) and utm_source=google from a bare gclid (YouTube Studio promotions, which strip utm). Fired from PostHogProvider.tsx; the OAuth callback path routes through identifyUser too.
  • Reverse proxynext.config.js rewrites /ingest/* → PostHog US cloud (+ skipTrailingSlashRedirect), and posthog.init uses api_host: '/ingest' so ad blockers (which blacklist *.posthog.com) can't drop 10-25% of events.
  • Scoped session replaydisable_session_recording: true at init, then startSessionRecording() only for shouldRecordSession() (= isPaidAdVisitor()), so replay volume/cost targets the cohort we're studying. maskAllInputs: true. Requires the project-level "Record user sessions" toggle ON.
  • Poster engagement pills (GrapheneClient.tsx + season-engagement.ts) — the bottom-left poster pill (super-admin on every season; creators on their own via /my-engagement-stats) shows 📚 published/total · 👁 views · 📖 reads · 🎧 listens · ▶ trailer-plays · ✅ completions · 👥 follows · 🔥 N/wk, with a richer hover tooltip (all-time + 7-day breakdown + an audio line: chapter-listens / completions ≥90% / distinct chapters reached / minutes heard). getSeasonEngagementStats returns: distinct-identity counts for views/reads/listens/trailer-plays (all-time + trailing-7-day), follows/follows_7d (rows in season_follows, migration 218), and audio-depth aggregates off chapter_listen_sessions (migration 121) — chapter_listens (total identity×chapter sessions), chapter_completions(+_7d) (the completed ≥90% flag), distinct_chapters_listened (distinct episode_ids reached), and listen_minutesmax_progress_seconds/60). The new fields are optional on the frontend EngagementCounts so stale cached payloads degrade gracefully. Both /admin/engagement-stats + /my-engagement-stats routes pass the service output through unchanged — no route edits needed to add a stat. Trailer plays dual-write a trailer_play row to season_engagement_events (TrailerHero → season-engagement-beacon.ts/track-engagement); migration 353 widens the kind CHECK to allow it. Landmine: recordEngagementEvent has its own kind guard that must also be widened for any new kind (was missed for trailer_play — the row never persisted until both the CHECK and the guard allowed it).
  • Cold-hero hook resolution + chapter handoff (ColdAdListenHero.tsx) — the hook plays, in priority: standard trailer → Maxell ad (maxell_ad_video_url — the ad creative most clicked, strongest continuity; only ~3 seasons have a trailer but ~10 have a Maxell ad) → first currently-free chapter (so the hero fires for the 141/144 trailerless seasons too). When the hook ends, a "Keep listening — Chapter N" handoff plays chapter 1 inline; when that ends, a Follow CTA fires (warm moment). Chapter is only ever one that's currently free (chapter_free_until null/future — never gives away paywalled audio). Events: cold_ad_listen_hero_shown (carries source: trailer|maxell_ad|chapter), cold_ad_listen_hero_play (chapter, stage: continue|direct), cold_ad_listen_hero_chapter_ended.
  • Follow survives signup (SeasonFollowButton.tsx) — an anonymous "Follow" tap carries ?autofollow=1 through signup; on return the bootstrap completes the follow once (then strips the param). Without this the serial-fiction re-engagement loop (chapter-drop emails) never started. The loop's far end: the hourly chapter_followers_notify_tick (chapter-notifications.ts) emails all followers-with-email when a chapter's audio renders, deep-linking #chapter-<n> (was a no-op ?ep=<n> the reader ignored) on graphene.fm (was hivejournal.com — both it + the weekly reader-digest now use grapheneOrigin()).
  • View-as "Ad visitor" preview modes (view-as.ts + ViewAsPicker.tsx) — the eyeball menu gains "Ad visitor — Meta/YouTube" (only on season + /graphene routes) so a super-admin previews the cold-ad hero without clicking a real ad. isAdViewAs(mode) flips the cold-hero gate reactively; deliberately separate from isPaidAdVisitor()/shouldRecordSession so previews aren't recorded or counted as real ad traffic.
  • Viewport-gated impression events (useImpressionRef.ts) — season_trailer_view (TrailerHero.tsx) and cold_ad_listen_hero_shown (ColdAdListenHero.tsx) used to fire on component mount, so instant-bounce ad traffic inflated the play-rate denominator. The shared hook fires the impression only after the element is ≥50% visible for ~1s (IntersectionObserver, SSR-safe, fires immediately as a fallback if IO is unavailable). Added 2026-06-10 after the funnel readout found the FB-ad cohort had a 4.9s median time-on-page yet every one counted as an impression. Convention: CONVENTIONS.md → Impression analytics must be viewport-gated.
  • Ad-spend efficiency dashboard (/dashboard/admin/ad-efficiency, super-admin) — at-will per-paid-campaign funnel straight from PostHog (visitors → real>2s → listen → 90s → modal → checkout) with a fraud% column (share of "visitors" bouncing under 2s — the bot/click-farm tell). Type spend per campaign to get cost-per-real-visitor / cost-per-listen live (client-side math); logged marketing_spend (the CAC dashboard's table) prefills where (source, campaign) match, and unmatched logged spend is surfaced separately. Backend: services/ad-spend-efficiency.ts (HogQL grouped by utm_source/utm_campaign, utm_medium in ('paid','cpc'), fail-soft like season-traffic-by-source) behind GET /api/admin/ad-efficiency?since=48h (routes/admin-ad-efficiency.ts, since accepts <n>h/<n>d/YYYY-MM-DD). The UI twin of the CLI scripts/ad-spend-efficiency.mjs. Built 2026-06-10 after that readout found ~1,050 paid visitors / 48h → 1 listen, 94–100% sub-2s bounce — bot/fraud, not a UX problem.

Chapter cover images (June 2026)

Per-chapter cover art, generated + displayed in the reader. Migration 354 adds chapter_cover_image_url + chapter_cover_prompt to story_episodes. chapter-cover-images.ts: an LLM (gpt-4o-mini) reads the chapter prose (head+tail sample for long chapters) and picks the single most visually striking scene/moment — what actually happens — as a scene-only image prompt; gpt-image-1 renders it in the season's effective visual style (getEffectiveStyleForSeason → book visual_style_override ?? universe visual_style), anchored to the universe's style_reference_image_url (357) via images.edit when one exists (style-only prompt; silent fallback to text-to-image). Character references: the scene pass also returns which known characters appear (from the universe canon characters + the season cast, whichever have portraits); their portraits (universe canon portraits 356 / story_cast.portrait_url, themselves in-style) are passed as additional images.edit reference images so recurring characters render recognizably — capped (MAX_CHARACTER_REFS=2, MAX_TOTAL_REFS=3 incl. the style anchor) with the prompt told to match face/hair/wardrobe; buffers memoized across the run. Style + character set resolved once per run. URL cache-busted (?v=) so force-regens (e.g. after a style change) actually refresh. Stored in season-assets/chapter-covers/<episode_id>.png. On-demand only (admin): POST /:id/chapter-covers (bulk) + POST /:id/episodes/:n/chapter-cover (single), via GenerateChapterCoversButton. Displayed as thumbnails in the chapter playlist (falls back to season poster). Cost ~$0.04/chapter — never auto-fires on the chapter pipeline. Read-page parallax tracking: the GET /:id/chapters endpoint also surfaces chapter_cover_image_url per chapter, and the reader's parallax poster (ReadClient.tsx) crossfades to the active chapter's cover as the reader scrolls — driven off the same nowReadingChapter scroll-spy that updates the sticky-header chapter title. Implemented as a second <img> overlay layered over the base season poster; its src is set imperatively (preloaded off-DOM, then swapped once decoded so there's no half-loaded flash) to survive the per-frame scroll re-renders, and only opacity animates. Chapters without a cover (or the top-of-page hero) fade the overlay out to reveal the season poster underneath.

Journal songs — Eleven Music for journalers (June 2026)

"Every form" for the writer, not just the show. Migration 423 adds journal_songs (kind 'entry'|'weekly', status, song_url, lyrics, prompt; (user_id, created_at) index; unique (user_id, period_start) WHERE kind='weekly') + profiles.weekly_journal_song_opt_out (default false). Both modes reuse composeMusic() from song-gen.ts (Eleven Music POST /v1/music).

  • On-demand "song from this entry" (journal-song.ts): createEntrySongJob() does the sync guards — ownership + rate limit (JOURNAL_SONG_MONTHLY_LIMIT, default 4 entry-songs / trailing 30d) — and inserts a generating row; runEntrySongJob() does the async LLM direction (journal_song.direction, a gift-not-therapy lyric prompt) → Eleven Music → upload (journal-songs/<user>/<id>.mp3) → ready/failed. Routes (authed, /api/journal-songs): POST /from-entry (202; 429 at cap, 403 not-owner, 404 missing), GET / (poll/display), GET /usage. UI: EntrySongPanel on the entry page.
  • Recurring "song from your journals this week" (journal-weekly-song.ts): on-by-default per user, opt-out. runWeeklyJournalSongs() picks eligible users (≥1 entry this ISO week, not opted out, no weekly song yet — the unique index makes it idempotent → one/user/week) and generates up to MAX_PER_RUN (default 25) per tick. Hourly cron runJournalWeeklySongLoop in index.ts with heartbeat journal_weekly_song_tick, CRON_REGISTRY['journal_weekly_song'] + isCronEnabled guard, CRON_LABELS. Two master gates (Eleven Music cost burner): the cron toggle AND env JOURNAL_WEEKLY_SONG_ENABLED (defaultEnabled: false → spend never starts on deploy; flip on at /dashboard/admin/crons when ready). Opt-out: GET/PATCH /api/journal-songs/weekly-pref + a toggle in EntrySongPanel.

Rights: input is the user's OWN private entries; access gated to the owner. Exact-lyrics mode (shipped): composeMusic() sends the LLM lyrics (name-scrubbed — the same text displayed) to Eleven Music as a composition_plan so the sung lyrics match the shown ones. This closed the "printed lyrics don't match the sung audio" bug — prompt mode had Eleven Music invent its own lyrics. Falls back to prompt mode if lyrics are null or the plan request errors.

  • Public sharing + browser (Migration 424 adds is_public (default true), cover_url, title): songs are public-by-default (lyrics generated name-free), surfaced anonymously (no author/source entry). Feed GET /api/journal-songs/public?limit= (default 24, cap 100; soft-auth stamps per-row can_hide for the owner/super_admin). Single song GET /api/journal-songs/public/:id (share page + OG unfurl; 404 unless public+ready). Unpublish PATCH /api/journal-songs/:id/public { is_public } (owner or super_admin). Surfaces: the graphene home row GraphenePublicSongsRow (now with a per-card Share (SongShareButton, native share sheet → clipboard) + an All songs → link); the all-songs browser/player at /songs (page + metadata layout) — includes a "Part of the HiveJournal network" cross-sell strip (HiveJournal / Odessa / Emberkiln / write.cafe); and the per-song share page at /songs/[id] (server page + client SongShareView: hero player, lyrics, "Write yours →" CTA). Both pages ride the graphene.fm passthrough routing (live at graphene.fm/songs); share links use the graphene.fm domain. Purpose-built 1200×630 OG cards (edge @vercel/og): /api/og/song/[id] (cover art + title) for a single song, and /api/og/songs (collection title + a strip of real cover thumbnails from the feed) for the browser — both wired into the pages' generateMetadata/layout as summary_large_image.

Chapter theme songs — Eleven Music (June 2026)

"Every form" extends to music. Migration 422 adds chapter_song_url / chapter_song_lyrics / chapter_song_prompt / chapter_song_generated_at to story_episodes. song-gen.ts generateChapterSong(episodeId): an LLM (DEFAULT_MODEL_KEY, feature_key song.direction) turns the chapter prose + show context into a music prompt + suggested lyrics, then calls ElevenLabs Eleven Music (POST https://api.elevenlabs.io/v1/music, same host + xi-api-key as ttsSegment) — composeMusic() sends the lyrics as a composition_plan (exact-lyrics mode — sung lyrics match the displayed ones), or { prompt, music_length_ms } prompt mode when there are no lyrics / the plan errors; model_id: 'music_v1', returns the MP3 buffer, metered via recordGenerationCost (provider elevenlabs, feature_key song.compose). Uploaded to season-assets/songs/<season>/<n>.mp3 (cache-busted) + persisted. Route POST /:id/episodes/:n/theme-song (super-admin, fire-and-forget 202 — Eleven Music gen is ~30–60s). UI: a per-chapter 🎵 button (ChapterSongButton) in the playlist row beside the audio/cover buttons; when a song exists the links out to play it. Provider notes: Eleven Music is our incumbent vendor (no new key/account), gated to paid plans — a missing key / 401-403 surfaces a clean "is Eleven Music on the plan?" error rather than crashing; "cleared for nearly all commercial uses" per ElevenLabs (confirm output-ownership terms before a user-content surface like song-from-a-journal-entry). Exact-lyrics mode (shipped): buildMusicCompositionPlan() turns the lyrics into a composition_plan (blank-line blocks → sections, a leading [Chorus]/(Bridge)/Verse 2: line becomes the section name, max 30 lines/200 chars each, durations split proportional to line count) so the SUNG lyrics are the ones we store/display — golden-tested in song-composition-plan.test.ts. Prompt mode remains the fallback (null lyrics / plan request error). Per-generation credit cost isn't in the public docs — visible on the plan.

Universe visual style + character portraits (June 2026)

A managed, per-universe visual "style" that every book in it adopts, so a universe reads as one visual world. Migration 355 adds story_universes.visual_style / midjourney_sref / style_reference_notes + story_seasons.visual_style_override; effective style = visual_style_override ?? universe.visual_style, resolved by visual-style.ts getEffectiveStyleForSeason() / getUniverseStyle(). Hybrid engine (auto + manual):

  • Set it — the "Visual style" section in UniverseEditorModal.tsx (plumbed through updateUniverse's field whitelist). Admins can open this editor — and kick off canon-character portrait generation — straight from the "Characters in [universe]" section of a season page (SeasonClient.tsx, editingKey={universeCanon.key}), not just the seasons-admin page. The season's universe_canon payload now carries portrait_url per character (merged from getUniverseCharacterPortraits in the GET /api/story-seasons/:id handler), so the cards render art on first paint; clicking Generate starts a 6s poll of GET /universes/:key/public that shimmers each empty card (animate-pulse) and swaps in finished portraits live — no reload.
  • Style-reference IMAGE anchormigration 357 adds story_universes.style_reference_image_url (distinct from the display cover_image_url and from a regenerated season poster_url). When set, both portrait pipelines go image-to-image via gpt-image-1 images.edit (the shared dalleEditToBuffer + fetchImageBuffer from season-media), anchored to that image — the only way to actually match a Midjourney aesthetic, which the visual_style text can only approximate. getEffectiveStyleForSeason() returns reference_image_url (always the universe's — a per-book text override carries no image). Critical: images.edit otherwise copies the reference's subject + composition, so the reference path swaps in a style-only prompt ("render in the EXACT art style… do NOT copy the reference's people/composition; invent a fresh single figure"). Fetched once per gen run; any fetch/edit miss silently falls back to text-to-image (purely additive — universes with no reference behave exactly as before). Set via the "Style reference image" uploader in the editor (reuses CoverImageField, universe-covers bucket).
  • Book cast + poster adopt itseason-media.ts prepends the effective style to the poster + every cast portrait prompt (regen visuals to apply); cast portraits use the reference-image anchor when set.
  • Universe canon charactersmigration 356 adds the universe_character_portraits side table (keyed by universe_key + character_key = BibleCharacter.key, source auto|upload). universe-character-portraits.ts renders each canon character in the style (gpt-image-1, prompt built directly from canon fields; image-to-image when a style-reference image is set). The /universes/:key/public payload attaches portrait_url per character; the gallery on /universes/[key] shows them via the CharacterPortrait client island. Editor button: "🖼 Generate character portraits".
  • Manual override (Midjourney) — per-character ⬆ upload (admin) on both surfaces: universe canon characters (CharacterPortrait → POST /universes/:key/characters/:characterKey/portrait-upload) and season cast (CastPortraitUploadButton.tsxPOST /:id/cast/:castId/upload-portrait). Both sharp-normalize to a square PNG, cache-bust the URL, and mark source='upload' so a later bulk auto-regen won't clobber hand-made art. Midjourney has no API, so the upload path is how MJ art enters.

Share + retention loop — every surface a recruitment surface (May–June 2026)

A sweep of ~20 PRs (#384–#411) that turned every Graphene + Studio + write.cafe surface a maker, a listener, or a share-arrival recipient lands on into a viral / retention surface. The thesis: most platforms treat distribution as a separate stack (marketing automation, ads, growth team). Here every share button, every OG card, every empty-state callout, every reader chrome element is wired with UTM stamping + analytics events + a one-click path back to the maker-side onboarding. The result is a closed loop — visitor sees a shared audiobook → arrives at the chapter reader → the same page nudges them toward "make your own" with a Studio prefill → they sign up, make a season, and the post-creation page hands them a share kit.

  • Studio-side recruitment surfaces
    • /studio/new accepts URL params for prompt + title + genre + voice (#384) so any shared link can pre-fill the upload form. The /examples curated prompt library (#389) at apps/frontend/src/app/examples/page.tsx is the canonical entry to this — every card is a one-click "→ open in Studio" link with the params attached.
    • /studio/welcome modernized (#398) — prompt-first paths, examples link surfaced, admin URLs removed from creator-facing onboarding.
    • /studio/dashboard outbound links UTM-stamped + "browse example prompts" path added (#410) so we can attribute creator-side retention back to specific entry points.
    • OnboardingTour "listen to a few existing shows first" preview link (#411) — new creators land on graphene.fm to see the product they're about to ship before they ship.
  • Maker-becomes-distributor surfaces (post-creation share kit)
    • First-render celebrate moment on /seasons/[id] when an audiobook just landed (#395) plus post-creation share moment (#385) — the maker becomes the distributor the moment the render finishes.
    • ShareAudiobookCard with Bluesky / X / Email buttons + UTM-stamped URLs (#386); cross-promotes portfolio share inside the same card (#401).
    • "Make another" card on /seasons/[id] (#408) — creator retention surface; once you've made one, the platform's next prompt is to make another.
    • MakerShareCard on /writers/[handle] (#396) — share the whole portfolio with one link, not one audiobook at a time.
    • Per-chapter share button on each chapter header (#403) — viral atom is one chapter, not one whole season; share the cliffhanger.
  • Embed pipeline (oEmbed) —
    • oEmbed support + iframe-friendly embed page at /seasons/[id]/embed (#388) lets Substack / Notion / Reddit / any oEmbed consumer inline a Graphene audiobook player. The embed_loaded + embed_audio_played PostHog events (#404) measure embedded-surface conversion.
    • Writer OG card surfaces audiobook count + poster mosaic for Graphene context (#392) — a /writers/[handle] link unfurled on Twitter/Slack shows the maker's actual catalog, not a stock avatar.
  • Share-arrival recipient surfaces (the other end of every share)
    • Inline "make your own" callout for share-arrival recipients on /seasons/[id]/read (#387) — when the URL carries ?from=share, the chapter reader injects a maker-recruitment moment without disrupting the listen.
    • WriterArrivalCallout on the maker hub (#402) — same pattern for whole-portfolio shares.
    • "More by [maker]" discovery row on /seasons/[id]/read (#400) — every chapter is a discovery surface for the rest of the maker's portfolio.
    • "Keep going — more from [universe]" same-universe discovery band (MoreFromUniverseRow.tsx) — mounts on /seasons/[id]/read after the chapter list, just ABOVE the "More by [maker]" row, so the stronger signal (shared world) leads. Filters the cached public slate (loadPublicSeasons()) by the season's universe key, excludes the current show, surfaces up to 3 as poster cards with the universe's accent (describeUniverse). Self-hides for standalone shows or universes with no siblings. Deliberately placed BELOW the end-of-chapter tip + floating share CTA: the give-back-to-this-story actions keep the emotional peak, and this band catches the larger set of readers who'd otherwise bounce at "The End" and routes them into the next session. Links stamp utm_source=more-in-universe. (Required adding universe / total_episodes / source_panel_image_url to the PublicSeason interface in graphene-public-seasons.ts — the /api/story-seasons/public endpoint already returned them.)
  • Listener retention surfaces
    • Follow button on the reader preamble (#407) with Spotify-listen badge alongside (#409) — the choice "follow on Graphene OR listen on Spotify" is the listener's, and we count both.
  • Browse-side recruitment surfaces
    • "+" make-your-own FAB mounted on /graphene (#393) — every browse session is a make-your-own session in disguise.
    • "Make yours" CTA on the /writers directory (#394) — browsing writers converts to becoming one.
    • "Make your own audiobook" tertiary link in the /graphene hero (#405).
  • Defensive infra
    • HTML-entity decoder on email subjects in safeResendSend (#406) — a small but important fix: HTML-encoded subject lines (e.g. &amp;, &apos;) were reaching inboxes literally. Now stripped at the boundary.

The whole loop is observable from one admin reference page — see the Marketing funnel reference entry below — which surfaces the share + retention surfaces grouped by stage with their analytics-event names so a super-admin can see at a glance which surface is converting + which is wired but cold.

Marketing funnel reference (/dashboard/admin/marketing-funnel)

The reference panel that catalogs every share + retention surface across Graphene + Studio + write.cafe + the reader, grouped by funnel stage (visitor → arrival → conversion → retention → distribution) with the PostHog event name + source surface URL + UTM convention per row (#412). The page is purely a reference (no live counts) — the source of truth for "where is this surface, what event does it fire, what UTM does it carry" when an analytics question lands. Pairs with the conversion-funnel panel on /dashboard/admin/monetization (which DOES show live counts) — this page tells you what surfaces exist; that page tells you which are converting. A bottom "Active distribution tools" pane links to the social-posting console (the surface that drives distribution) + the seasons admin (the surface that produces the assets).

Social-posting console — pick 5, paste-and-go for YouTube Shorts / Reels (/dashboard/admin/social-posting)

The console that closes the distribution loop. Picks the next ~5 best-candidate shows for a chosen platform (YouTube Shorts default; FB Reels / TikTok / IG Reels wired but cold), surfaces the rendered 9:16 MP4 + auto-drafted social caption + a one-tap "open the upload page" link + a ✓ Mark posted action with optional public-URL capture, and renders a recent-posts log below so we can see what's been shipped + click through to the live post. Migration 343_social_post_log.sql introduces the social_post_log table (indexed on (season_id, platform) for NOT-EXISTS candidate filtering); services in social-posting.ts (candidates / needs-prep / log read/write); routes mounted at /api/admin/social-posting in admin.ts (#431).

  • Candidate filteris_published_to_graphene = true AND trailer_vertical_video_url IS NOT NULL AND social_caption_draft IS NOT NULL AND no social_post_log row for (season_id, platform) yet. Sorted by trailer_vertical_rendered_at DESC so freshly-rendered shows surface first. Caller-side dedup against social_post_log.season_id filtered to the chosen platform (Supabase's .not('id', 'in', '(...)') chokes on dynamic large lists, so we over-fetch + dedup in JS — fine while the catalog is small).

  • Needs-prep panel (#433) — separate getNeedsPrepSeasons() query for published shows that DON'T yet have the trailer / vertical / caption. Renders above the candidates grid with per-stage ✓/○ chips (horizontal, vertical, caption), an inferred next_step label (render_horizontal_then_vertical / render_vertical_only / draft_caption_only), and a one-tap ▶ Build short button that fires the chained render-short-pipeline endpoint. Optimistic in-flight state survives until the next refresh confirms the season moved out of needs-prep. Lets you drive a backlogged show from "I just published it" to "ready to post" without leaving this page.

  • Chained short pipeline (POST /api/story-seasons/:id/render-short-pipeline#433, #434) — one tracker event chains horizontal trailer (if missing) → vertical wrap → caption auto-draft → ad pack (square MP4 + thumb JPG + 3 caption variants). Skips horizontal if cached. Ad-pack failure isolated from the short assets the creator already needs. Backs the ▶ Build short + ad pack button in StageStrip's trailer stage on /seasons/[id] (super-admin gated).

  • Ad pack export (#434) — migration 344_season_ad_pack.sql adds ad_pack_square_video_url (1080×1080 wrap for FB feed) + ad_pack_thumb_jpg_url (1080×1080 JPG pulled from the 25%-mark frame, for FB creative / Goodreads card / Amazon A+ banner) + ad_pack_captions jsonb ({ fb: { primary_text, headline, description }, goodreads: { text }, amazon: { text } } — tuned per-surface; Amazon variant deliberately avoids their rejected words like "best/exclusive/free/#1/guaranteed") + ad_pack_rendered_at. Service season-ad-pack.ts does the ffmpeg square wrap + thumb extract + gpt-4o-mini JSON-mode caption draft (~$0.001/pack). Standalone re-render at POST /:id/render-ad-pack. UI surfaces via AdPackPanel inside the expanded trailer stage panel on StageStrip — square / thumb download links + three caption columns with per-row copy buttons + char counts. The Studio-creator upsell story: same engine that ships your audiobook also produces your FB ad creative + Amazon A+ copy.

  • YouTube Shorts one-tap upload (#437) — migration 345_youtube_credentials.sql introduces a singleton youtube_credentials table for the connected channel's OAuth tokens (partial unique index enforces exactly one active row). Service youtube-upload.ts mirrors the google-calendar.ts OAuth shape (buildAuthUrl / exchangeCodeForTokens / refreshAccessToken / getActiveCredentials with auto-refresh / revokeToken) and hand-rolls the multipart/related upload body (no googleapis package — saves ~7 MB bundle). Routes at routes/youtube.ts: GET /oauth/start (super-admin), GET /oauth/callback (public, state-validated via auth.admin.getUserById + isSuperAdmin), GET /status, POST /disconnect, POST /upload (downloads the vertical MP4, uploads, auto-logs to social_post_log). Setup guide at docs/setup-guides/YOUTUBE_UPLOAD_SETUP.md. UI: connection bar at the top of the console (Connect / Connected as <channel> / Disconnect) + per-candidate ▶ Post to YouTube button next to the existing ↗ Open YouTube link. Quota: YouTube's default 10k-unit/day ceiling allows ~6 uploads/day (1600 units per videos.insert); request a quota increase via Google Cloud Console once volume requires it. Description gets #Shorts auto-appended for the Shorts classifier.

  • Cmd-K palette entry▶ Social posting console with synonyms social / posting / post / youtube / shorts / reels / tiktok / instagram / fb / facebook / marketing / distribute / upload. Super-admin gated.

  • Two-asset YouTube uploads (PRs #492 + #493). Each candidate row exposes both ▶ Post trailer (existing vertical wrap) AND 💭 Post maxell ad (the thought-bubble pilot format) when both assets are rendered. Migration 349 adds social_post_log.asset_kind so the candidate-list dedupe runs per-(season, platform, asset_kind) — same show can ship the trailer AND the maxell ad as separate Shorts; once both posted, the row drops off the candidate list. Posted-state shown as green "✓ Trailer posted" / "✓ Maxell ad posted" chips in place of the buttons. Backend POST /api/youtube/upload accepts asset?: 'trailer' | 'maxell_ad' (PR #492) + optional video_url override (PR #502) so per-render uploads from the maxell editor work too.

  • Build Short auto-heals missing audio (PR #495). The render-short-pipeline route adds a step 0 pre-flight that checks season_audio_segments; when segments don't exist or all audio_urls are null AND no horizontal trailer is cached, the chain runs renderSeasonAudio first, then proceeds. Detail string starts with Audio (auto-healed): X/Y TTSed so the operator sees in the runs feed that the chain recovered. Adds minutes to the chain when it fires but only on the cold path (published show, never been through render). The 202 response's ~1-2 min estimate becomes ~5-8 min when auto-heal triggers; runs feed reflects truth.

  • Body-parser limit ordering (PR #498). Per-route express.json({ limit }) for /api/manuscript (25mb) and /api/maxell-reels (75mb) MUST mount BEFORE the global app.use(express.json()) — otherwise the global one runs first with the 100kb default and 413s the request before the per-route limit gets a chance. Body-parser is a no-op once req.body is populated, so the per-path parser wins for matching paths and the global one harmlessly skips for them. Pattern in apps/backend/src/index.ts lines ~218-230.

  • YouTube Shorts rail — PARKED (PRs #504 → #505 → #506). Backend endpoint GET /api/graphene/youtube-shorts reads social_post_log for platform='youtube_shorts', extracts the canonical 11-char video ID from any URL shape (/shorts/<id> · /watch?v=<id> · youtu.be/<id> · /embed/<id>), de-dups by season, caps at 12, hydrates with season metadata. Component apps/frontend/src/components/graphene/YouTubeShortsRail.tsx renders a horizontal snap-scroll rail of thumbnail-link cards (NOT iframe embeds — too heavy, and inline play would steal attention from the rest of /graphene). Not currently mounted on /graphene — the rail was shipped, then unmounted in #506 because the operator's funnel call was right: sending /graphene's already-thin traffic OUT to YouTube competes with the on-site listen → paywall funnel rather than feeding it. Component + endpoint stay parked for a future per-show /seasons/[id] mount where the visitor is already inside the intent funnel for THAT show, so a Short of that same show is "watch a 30-sec taste" rather than "leave Graphene to browse YouTube." One-line mount when ready: import + <YouTubeShortsRail /> somewhere on SeasonClient.tsx. Analytics event graphene_youtube_short_clicked stays in the type union for the eventual mount.

Maxell thought-bubble ad (pilot format)

The platform's vertical 1080×1920 marketing format. Bottom layer is one of six "maxell silhouette" reels (a person listening in shadow, warm gradient); a thought-bubble overlay with the show poster + title + karaoke-synced premise/highlight/pullquote sits over them, with TTS narration in the show's narrator voice. Designed for Instagram Reels / TikTok / YouTube Shorts. ~20-30s, ~$0.005 per render.

  • Service: apps/backend/src/services/season-maxell-ad.tsrenderMaxellAd(seasonId, opts) is the entrypoint. Reads the resolved layout, picks a reel (random or pinned via layout.reel_idx), TTSs the karaoke text in the show's narrator voice, runs Whisper word-level timestamps, builds a karaoke ASS subtitle with embedded font, composites the bubble PNG (poster + gradient + optional play-button) via sharp, optionally mixes a Suno track from veneer_tracks under the narration, then assembles everything through one ffmpeg filter_complex graph. Uploads the final MP4 to adpack/<seasonId>/maxell-ad.mp4 and stamps maxell_ad_video_url + maxell_ad_rendered_at on story_seasons, plus prepends a MaxellAdRenderEntry to maxell_ad_renders jsonb (capped at 10).

  • Migrations:

    • 346_maxell_ad.sqlstory_seasons.maxell_ad_video_url + maxell_ad_rendered_at
    • 347_maxell_ad_layout.sqlstory_seasons.maxell_ad_layout jsonb (free-form so new editor knobs don't need migrations)
    • 348_maxell_ad_renders.sqlstory_seasons.maxell_ad_renders jsonb array of { url, rendered_at, source, source_label, duration_seconds, size_bytes, diagnostic? } capped at 10 entries
  • Routes in routes/story-seasons.ts (super-admin OR season owner):

    • POST /:id/render-maxell-ad body { highlight_id?, pullquote_ref? } — fire-and-forget, 202 with tracker event under stage 'trailer'. Voice-required (PR #476 pre-gates in UI). Throws cleanly if narrator_voice_id is null.
    • GET /:id/maxell-ad-layout — returns { layout, defaults, raw_override, season: { title, poster_url, premise, narrator_voice_id }, renders }
    • PATCH /:id/maxell-ad-layout body { layout } or { reset: true } — round-trips through resolveMaxellLayout to drop unknown keys + fill defaults
    • GET /:id/highlights — flat list of saved highlights for the karaoke source picker
    • GET /:id/pullquotes — LLM-picked magazine sentences from each episode's pullquotes jsonb, flattened with synthetic ref = pq:<ep>:<idx>
  • Editor at /dashboard/admin/maxell-ad/[seasonId] — sticky-left preview + scrolling-right controls. Preview mirrors the ffmpeg compose 1:1 in HTML/CSS (real <video> for the reel, sharp's SVG output → SVG/HTML for the bubble, drawtext positioning math → CSS flex/x_pct calc). Direct-manipulation: click any element to select → drag to move → corner handle to resize. Selected element auto-expands its config section + scrolls into view; other sections default-collapsed. Layout knobs (sections):

    • Source — picker grouped Premise (default) / Highlights / Pullquotes. Selection persists via layout.source_highlight_id or layout.source_pullquote_ref.
    • Title / Premise / Tagline / Karaoke — each has y, fontsize, color, shadow, max-chars, text alignment (L/C/R), font picker from MAXELL_FONT_CATALOG (Inter / Anton / Bebas Neue / Special Elite / Crimson Text). Title + Tagline editable text; Premise pulls from season; Karaoke uses the picked Source.
    • Bubble — x/y/w/h/corner-radius
    • Reel — horizontal scroll thumbnails (hover plays, click selects); 🎲 Random first or pin a specific one. Persists to layout.reel_idx.
    • Trailing circles + Play button — visibility + size/position for the bubble decorations
    • Gradient — start% + end opacity for the legibility scrim on the poster
    • Graphene wordmark — the GRAPHENE / .fm brand mark, rasterized via buildWordmarkPng() and overlaid as PNG
    • Background music — Suno track picker from veneer_tracks (cover thumbs + hover-to-play + size badge, with red BROKEN chip on sub-50KB stubs per PR #475). Volume / fade-in / fade-out / start-offset sliders + explicit ▶/⏸ preview that plays AT the configured volume FROM the offset.
    • Outro CTAenable='gte(t,start)' drawtext that flashes a CTA in the final N seconds. Default Listen and Read free at graphene.fm.
    • Custom elements — operator-added array of text labels + horizontal drawbox lines. Each one a selectable canvas element.
  • Render history — section at the bottom of the editor shows the last 10 renders as cards with inline <video> playback + download + source_label + relative time. Each card surfaces a one-line diagnostic breadcrumb (song: ... → mixed | no music, font embed size, Whisper word counts) so post-mortems don't require Railway log digs (PR #471).

  • Font handlingMAXELL_FONT_CATALOG ships 5 catalog fonts from jsdelivr CDNs. ensureMaxellFontsDir() downloads them to a stable tmp dir on first render. Title/premise/tagline drawtext layers use fontfile= with the absolute path. Karaoke font had to bypass libass's fontconfig path entirely (unreliable on Railway) — buildAssFontEmbed() UU-encodes the chosen font and inserts it as an ASS [Fonts] section before [V4+ Styles] so libass loads it during header parsing, ahead of any external lookup. Inline \fn{Family} override on the dialogue is the third belt + suspenders layer (PR #465).

  • Whisper realignmentrealignWordsToOriginal(karaokeText, rawWords) walks the original text + Whisper word tokens in parallel, alphanumeric-prefix matching to restore punctuation and spelled-out numbers that Whisper strips (Marcus Chen.Marcus Chen + thirty four.34 → restored back). Trailing punctuation gets re-attached to each word so the karaoke shows what the listener is reading along with (PR #467).

  • Render diagnostic surface[maxell-ad] fonts ready (N/5), [maxell-ad] karaoke font embed: family=X embed=NB words=Y aligned=Z, [maxell-ad] song: <breadcrumbs> → mixed | no music print to Railway logs AND the latest of these (diagnostic) is stamped onto MaxellAdRenderEntry.diagnostic and surfaced under each card on the editor's Render history pane.

  • StageStrip integrationStageStrip.tsx 'trailer' stage exposes:

    • 💭 Maxell ad (pilot) — opens MaxellAdSourcePicker modal to pick premise / highlight / pullquote, then fires /render-maxell-ad. Disabled when !hasVoices (PR #476) — label becomes 🎙 Maxell ad — set voice first.
    • 🎨 Tweak layout — links to the layout editor at /dashboard/admin/maxell-ad/[seasonId]
    • The completed-render display shows the MP4 inline with 📥 Vertical MP4 + ↗ PREVIEW + a re-render shortcut.
  • Color + audio normalization (PR #468):

    • Video colorspace: source reels are sub-720p BT.601; output is BT.709-coded. Without explicit conversion the YUV matrix gets misread and reds oversaturate ("background coming through very red"). Filter chain runs colorspace=all=bt709:iall=bt601-6-625:fast=1 post-scale. H.264 output is tagged with -colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tv so players don't guess.
    • Audio mix: TTS premise is mono 24kHz; Suno songs are stereo 44.1kHz. amix doesn't auto-resample. aformat=sample_rates=44100:channel_layouts=stereo on BOTH inputs before amix; volume=2.0 after amix undoes amix's built-in 1/N normalization at 2 inputs.
  • Validation guards:

    • Veneer-tracks upload + Suno import reject sub-50KB files (PR #473) — Suno share-page scraper sometimes returns a streaming-endpoint seed file (~4-5KB) instead of the full track. Operator-facing error message points at the "from disk" upload fallback.
    • Song picker + veneer-tracks admin show BROKEN chips on existing sub-50KB rows (PR #475) — for legacy tracks that pre-date the upload validation.
    • In-place "Replace audio" fix for stub rowsPOST /api/admin/veneer/tracks/:id/replace-audio (multipart file) → replaceTrackAudio() in veneer-tracks.ts overwrites the stored MP3 at the same storage path (loudnorm re-master, re-probe duration, update file_size_bytes, cache-bust mp3_url with ?v=); title/cover/status/source untouched. Surfaced as a per-row "⤒ Replace audio" button (amber when the row is a stub) on the veneer-tracks admin page; the maxell-ad editor's song picker shows a stub nudge linking there. This is the fix for the "songs won't play" symptom — a stub plays as silence, replace swaps in the real MP3 without losing the cover.
    • Active-pipelines widget 401 fixActivePipelinesWidget.tsx previously cached the Supabase access token captured at mount and reused it on every 8s poll; after the ~1h JWT expiry each poll fired a stale Bearer → 401 console spam. Now reads a fresh token off a stable supabase client via getSession() on every tick (supabase-js auto-refreshes it), and skips the poll when no token is available.
  • Reels library — first-class table (maxell_reels, migration 350, PR #496). Until #496 the six silhouette reels lived as static files in apps/frontend/public/reels/ and were selected by hardcoded index. Adding a new reel meant dropping the MP4 in the repo + bumping a constant + redeploying. Now operators paste a direct-MP4 URL (Midjourney / Sora / Runway / any public MP4) or upload a file from the maxell ad editor's reel picker → reel lands in the library + auto-pins for the next render. The six builtins seed with deterministic UUIDs (00000000-...-00000000000{1..6}) pointing at the existing static paths, so legacy layouts pinning by reel_idx keep working. Imported reels live in Supabase Storage under season-assets/maxell-reels/<uuid>.mp4. Per-asset render flow in season-maxell-ad.ts calls resolveRenderReel(layout) → reel_id → reel_idx → random fallback. Imports gracefully reject CDN URLs that require auth (Midjourney, Sora, Runway) — operator gets a clear "use the upload tab instead" message (PR #497). Non-9:16 sources scale-to-cover + center-crop (PR #500) so a square Midjourney clip doesn't crash ffmpeg.

  • Bubble visibility persists (PR #501). The editor's 👁 bubble chip was preview-only pre-#501; now it mirrors layout.bubble.visible and the renderer skips the rounded-rect overlay when hidden. Title/karaoke/wordmark/outro CTA still draw at their absolute positions. Same pattern the trailing_circles chip already had.

  • Remix from another show (PR #494). The editor's sticky footer has a 🎨 Remix from another show button. Picker modal lists every show with a saved maxell_ad_layout (poster + title + last-render timestamp); pick one → the full layout copies onto the current show. source_highlight_id + source_pullquote_ref get scrubbed during the copy since they reference content tied to the source show. Owner-or-admin gate on both ends. Backend: GET /api/story-seasons/maxell-ad-layouts/list + POST /api/story-seasons/:id/maxell-ad-layout/copy-from.

  • Per-render "▶ YouTube" button (PR #502). Each render-history card in the editor has a third action chip (alongside 📥 MP4 and ↗ Open) that uploads THAT specific MP4 to YouTube — not just the latest maxell_ad_video_url. Backend: /api/youtube/upload accepts a video_url override that bypasses the season-column lookup. Posts log to social_post_log with asset_kind='maxell_ad'.

Per-show admin hub (/dashboard/admin/seasons/[id])

Canonical entry point for managing one Graphene show. Before this, per-show admin was scattered across 6+ surfaces with the admin chrome literally mounted on the public reader page; operators bounced between 4 URLs to do "tweak layout → render → grab share kit → schedule post." Consolidated in PRs #478 / #479 / #480 / #481.

  • Shell: page.tsx (server) + AdminSeasonHub.tsx (client). Server is a thin params-extractor → passes seasonId to the client. Client resolves the Supabase session for auth-aware fetches + hydrates the full /api/story-seasons/[id] payload.

  • Header: poster thumb + title + owner + universe chip + published/draft badge + ↗ View public + ← All shows back link.

  • Sidebar IA (5 tabs):

    • 🎬 Overview — inline SuperAdminPanel with the 9-stage StageStrip + all modal openers (VoicePicker, ScriptPreview, ThemesPreview, TakesModal, ProseVersionsModal) + Tools row. Same props the public page used to pass: {season, publishing} merged, cast, authToken, episodes, pipelineRunning, onPipelineStarted, onUpdate. The footer wayfinding hint lists the 7 modal-only tools (📋 Episodes · 🎧 Preview audio · 🎬 Render all · ✉ Letters · 🎭 Takes · 📊 Runs · 📜 Prose versions) so the IA is honest about where they live.
    • 🎨 Maxell ad layout — deep-links to /dashboard/admin/maxell-ad/[seasonId]. Stays as its own route because the layout editor's UI density would dominate any tab pane.
    • 📦 Distribution — deep-links to share kit.
    • 👥 Subscribers — deep-links to /dashboard/admin/season-subscribers.
    • 📊 Analytics — deep-links to /dashboard/seasons/[id]/analytics.
  • Tab state in URL — active tab persists in the ?tab= query param so refresh + share preserve location. useSearchParams()?.get('tab') with optional chaining since Next 15's strict types return ReadonlyURLSearchParams | null (PR #483 hotfix for the Vercel build).

  • Entry points:

    • ⚙ Manage chip on each row of /dashboard/admin/seasons. Placed first in the action button row so it's the default per-show entry.
    • 🛠 Admin hub ↗ button in PublishToolbar.tsx on the public /seasons/[id] page (owner/admin gated, always shown). Was the in-page #publish-admin anchor before #480 stripped the admin chrome; now a cross-route link with the new label so the operator knows they're leaving the public page.
  • What's NOT in the hub (intentional, owner-or-admin gated — different surface than the ops console):

    • ShareAudiobookCard, PaidTrafficUrlPanel, BlueskyAutopostPanel — these stay on the public /seasons/[id] page because they're part of the creator workflow (the show's owner sees them), not pure admin ops. The hub is the ops console; the public page is creator-aware. Two different audiences, kept separate.
  • What still lives on the public page for any visitor: the actual show display (title, poster, episodes, premise, characters, listen/read CTAs, tipping, BookCard, premium gating). Stripping the admin chrome left the public surface untouched.

Lighthouse — reconnection narratives for alienated parents (Phase 1, in progress)

Sibling of Odessa, but grounded truth reframed toward the future instead of metaphorical fiction. Product frame + safeguarding rules (never distribute child-facing before 18; practitioner-gated white-label through coach Ryan Thomas) live in docs/product/LIGHTHOUSE.md. Phase 1 is parent-facing only — nothing delivers to a child.

  • Engine: apps/backend/src/services/lighthouse.tsgenerateReconnectionNarrative() reuses Odessa's gatherUserContext() (journals + family_members + mood) but a distinct LIGHTHOUSE_ETHOS + prompt (truth-not-spin, no-blame, forward-looking, non-coercive, not-therapy). Runs on Claude (claude-sonnet-4-6, a careful model for a sensitive surface) via the shared metered complete() helper (feature_key: 'lighthouse.narrative', temp 0.7). Returns { title, to_the_parent, chapters[], letter_to_child, bridge_forward } — the letter_to_child is generated to be safe for a grown child to receive cold, but is NOT delivered in Phase 1.
  • Endpoints: apps/backend/src/routes/lighthouse.ts POST /api/lighthouse/generate (auth, { focus?, child_name?, lookback_days? }; lookback 0/omitted = whole record; preview, not persisted) + POST /api/lighthouse/create (persists the narrative as a notebooknotebook_type='lighthouse', narrative in lighthouse_config migration 449 — so the parent can revisit + print).
  • Surface (tool): apps/frontend/src/app/dashboard/lighthouse/page.tsxsuper-admin gated for now (admin-only FeatureMenu entry); the per-user pilot flag for a coach's clients is a later slice. Inputs → narrative → Save as keepsake → Download 6×9 PDF / Order a printed copy (PrintOrderModal).
  • Public landing + signup funnel: apps/frontend/src/app/lighthouse/page.tsx (+ metadata layout + OG card) at /lighthouse — a full marketing landing for alienated parents ("The door stays open" / "a beacon, freely followed" / never-before-18), whose signup form writes to lighthouse_interest (migration 450) via public POST /api/lighthouse/interest. On submit it fires a best-effort Slack nudge (opt-in env LIGHTHOUSE_NOTIFY_SLACK = a #channel/C… or an email to DM; no-op if unset — never blocks the signup). Super-admin review UI at /dashboard/admin/lighthouse-interest (reads GET /api/lighthouse/admin/interest), linked from the admin FeatureMenu. The coach partner (Ryan Thomas) is intentionally unnamed on the page for now — framed as "a guided program."
  • Print keepsake reuses the notebook 6×9 pipeline with NO journal pollution: fetchNotebookForPrint in print-export.ts branches on notebook_type='lighthouse' and builds chapters from lighthouse_config via the pure, golden-tested lighthouseNarrativeToPrintChapters in notebook-prose.ts (chapters → letter → bridge; the private to_the_parent mirror is never printed). Both the notebook download-PDF route and the Lulu print-order route light up for free. Test: lighthouse-print.test.ts.

Odessa — Personalized Story Generator

Reads a user's journal entries, life map, mood history, and personal context (configurable lookback: 7/14/30/90 days or all time), then generates a metaphorical graphic novel story seed. Characters are mapped from real people (different names, same dynamics), settings mirror the emotional landscape, and the 3-act arc offers a resolution the user might not have considered. The user co-develops the story through the existing graphic novel chat + DALL-E pipeline.

  • Analysis engine: apps/backend/src/services/odessa.tsgatherUserContext() fetches entries, life map nodes, satisfaction/energy readings, weekly review, family members. generateStorySeed() sends compressed context to GPT-4o-mini (JSON response format, temp 0.9) and returns a StorySeed with title, theme, genre, art style, setting, characters (with inspired_by hints), 3-act narrative arc, opening prompt, and resonance note.
  • Endpoints in apps/backend/src/routes/odessa.ts: POST /api/odessa/generate-seed (auth required, accepts { focus?, lookback_days? }), POST /api/odessa/create-story (creates a graphic_novel notebook pre-configured with the seed), GET /api/odessa/character-stats (returns live CharacterStats: energy, resilience, connection, clarity, momentum — derived from satisfaction readings, routine check-ins, @mentions, mood variance, task completion), GET /api/odessa/story-state/:notebookId (returns persisted story state + character stats + seed for a specific notebook).
  • Character stats (CharacterStats interface in odessa.ts): computeCharacterStats() queries 14-day window of satisfaction_quick_values, user_tasks, journal_entries, family_routine_checkins. Stats are injected into the generateNextEpisode() GPT prompt so character behavior mirrors real-life growth. Stats persist on StoryState.character_stats between episodes — delta descriptions ("energy rose from 3→7") are woven into narration.
  • Character stats display: apps/frontend/src/components/notebooks/CharacterStatsCard.tsx — renders on Odessa notebook pages. Shows 5 stat bars with delta indicators from previous episode. Also shows character arcs + episode/tension metadata. LiveStatsPreview component on the Odessa dashboard shows current stats before story generation.
  • Dashboard: apps/frontend/src/app/dashboard/odessa/page.tsx — focus input, lookback selector (7/14/30/90/all), atmospheric loading messages, seed card with resonance note + characters + 3-act arc + opening scene, "Start this story →" creates notebook + redirects to graphic novel editor.
  • Public landing: apps/frontend/src/app/odessa/page.tsx — "Journal. Pattern. World. Story." 4-step how-it-works, friction-reduction philosophy connection. Includes a "Turn your story into an audiobook" cross-sell section ("The life you wrote down becomes the story you press play on.") that surfaces the Odessa × EmberKiln promo card and links to /studio — closes the journal → fiction → audiobook loop on the landing page itself.
  • Share-card metadata: apps/frontend/src/app/odessa/layout.tsx — sibling layout carries OG/Twitter metadata (the page itself is 'use client' and can't export metadata). OG image is the promo card at public/promo/odessa-graphene-audiobook.png; summary_large_image Twitter card. The card + its sibling format/copy variants are generated from apps/frontend/public/promo/build-promo.mjs (copy deck × format geometry → HTML) and rendered to 2× PNG via render-promo.sh (headless Chrome). Rebuild: node build-promo.mjs && ./render-promo.sh.
  • Deep dive: docs/ai/features/odessa.md
  • Privacy: raw journal content never sent externally — only 300-char excerpts + mood/tags/mentions. StorySeed contains no real names. Graphic novel stored as standard journal entries.
  • Episodic continuation: POST /api/odessa/next-episode reads journal delta since last episode, computes character stats, generates 2-3 scenes with illustrated panels, persists updated StoryState. POST /api/odessa/resolve-branch records the user's choice at a narrative branch point.
  • Public share page: apps/frontend/src/app/stories/[id]/page.tsx — anonymous-accessible, fetches from /api/notebooks/:id/public.
  • Port-to-Graphene deep dive: docs/ai/features/PORT_TO_GRAPHENE.md — end-to-end walkthrough of the notebook→season pipeline (prose extraction, script-without-LLM, fire-and-forget TTS, PublishStrip polling, publish gating, edge cases).
  • Port to Graphene (with audio): POST /api/notebooks/:id/port-to-graphene (apps/backend/src/routes/notebooks.ts) creates a novel-mode story_seasons row + one story_episodes per notebook entry with chapter_prose populated from panel narration + dialogue (image_description skipped — visual brief, not audio-relevant). Body: { narrator_voice_id?, kick_off_audio? }. After insert, calls generateNovelScript() which sees the cached prose and SKIPS the LLM call, composing season_audio_segments directly from the writer's own text. Optionally fire-and-forgets renderSeasonAudio() so ElevenLabs TTS starts immediately. UI button "🎙️ Port to Graphene" on the notebook detail page (right next to Share Story) — small modal with voice picker (Grace/Sarah/Charlotte/Alice/Daniel/Antoni) + "Generate audio now" toggle. Owner-only. Creates owner_user_id-stamped season at status='draft', is_published_to_graphene=false.
  • Notebook→entry privacy sync: POST /api/notebooks/:id/sync-entry-visibility (apps/backend/src/routes/notebooks.ts) pushes the notebook's metadata_visibility + content_visibility down to every entry inside. Owner-only. The edit page prompts for this on visibility change at save time; the detail page renders an amber banner with a one-click "Apply to all entries" affordance when there's a mismatch. Background: visibility inheritance was always lazy at read time (SQL helpers in migration 004), but per-entry chips read each entry's own column, so an author who flipped the notebook to public would see entries still labelled "Only you" without this sync.
  • Port-to-Graphene round-trip (notebook ↔ season cross-links + live audio control) — the loop layered on top of the basic port endpoint above:
    • "Your stories" row on /graphene: GET /api/story-seasons/mine (apps/backend/src/routes/story-seasons.ts:406) — auth-only; returns the caller's seasons regardless of is_published_to_graphene, newest first, poster-shape fields only so PosterCard renders without modification. Surfaced above the public slate so writers see their own work inline rather than buried in /dashboard/admin/seasons.
    • Back-link season → notebook: GET /api/story-seasons/:id (apps/backend/src/routes/story-seasons.ts:1688) resolves source_notebook: { id, name } | null by looking up notebooks.graphic_novel_config->>ported_season_id scoped to the season's owner (so the probe can't enumerate other users' notebooks). SeasonClient renders a "← Source notebook: <name>" link just under PublishStrip (SeasonClient.tsx:840).
    • Back-link notebook → season: the port route stamps graphic_novel_config.ported_season_id + ported_season_at onto the source notebook on successful port (notebooks.ts:608-622). Once stamped, the notebook detail page swaps the "🎙️ Port to Graphene" button for a "🎧 View on Graphene →" link (notebooks/[id]/page.tsx:472); the notebooks-index poster grows a fuchsia "🎧 On Graphene" badge in the top-right corner stack (notebooks/page.tsx:567).
    • Title cleanup at port time (notebooks.ts:475-491, 530-535): strips the ^Odessa:\s* prefix from the season title (the notebook keeps its full name — the prefix flags Odessa notebooks in the index) AND strips the redundant [:\-—|]\s*<story name>\s*$ suffix from every chapter title (Odessa generates chapters like "Chapter 1: <story title>: Act 1, Scene 1" which doubles the story name in every TTS intro). Empty results fall back to "Chapter N" so no episode ever ends up titleless.
    • PublishStrip on /seasons/[id] (PublishStrip in SeasonClient.tsx:2239): owner/admin-only banner with inline publish/unpublish toggle (PATCH /api/story-seasons/:id/published), plus a one-click "🎙️ Re-render" button that POSTs to /api/story-seasons/:id/render-audio with { force: true } to re-TTS every chapter — useful after the title-cleanup backfill or any prose edit. SeasonClient polls per-chapter audio state while a render is mid-flight (SeasonClient.tsx:802); PublishStrip's audioProgress prop flips it to a live "rendered N of M chapters" progress bar until the render completes. Per-chapter "Audio rendering…" chip — when a novel-mode chapter has chapter_prose but no chapter_audio_url yet, the chapter row shows a fuchsia pulsing "Audio rendering…" pill instead of just hiding the Listen button. The existing 8s polling swaps it for the Listen button when the URL lands.
  • PostHog events: odessa_landing_view + odessa_landing_cta_clicked (top-of-funnel on the /odessa landing — paid flag + traffic source attached; the entry rungs the paid creative drives), odessa_seed_generated, odessa_story_started, odessa_full_story_generated, odessa_next_episode, odessa_branch_chosen, odessa_story_shared, odessa_story_ported_to_graphene (with kick_off_audio — the north-star when true: an audiobook now exists). Full funnel + distribution plan: docs/product/ODESSA_AUDIOBOOK_GTM.md.
  • Routes: /dashboard/odessa, /odessa, /stories/[id]

Graphic Novel Notebooks

A notebook type where the user co-develops a visual story with AI — or lets Odessa auto-generate the full thing. Content stored as JSON panels in the content TEXT field (same pattern as outline type).

  • Migration 080_graphic_novel_notebooks.sql extends notebook_type to include 'graphic_novel', adds graphic_novel_config JSONB (art style, characters, setting, tone, conversation history, odessa_seed).
  • Story generation: apps/backend/src/routes/graphic-novel.tsPOST /api/graphic-novel/generate-page (GPT-4o-mini for panel layout + DALL-E 3 for images in parallel), POST /api/graphic-novel/regenerate-panel, POST /api/graphic-novel/generate-character-sheet (4-pose DALL-E 3 HD reference sheet + revised_prompt capture for visual consistency).
  • Auto-generation: POST /api/odessa/generate-full-story drives the pipeline across 3 acts (Flash) or 9 scenes (Short Story) automatically. Accepts { length: 'flash' | 'short_story' }.
  • Editor: GraphicNovelEditor.tsx — split-pane (conversation 40% + comic panels 60%). Speech bubbles overlaid on images. Panel regeneration on hover.
  • Read-only renderer: GraphicNovelPage.tsx — used in notebook feed, entry detail, dashboard stream.
  • Storyboard: GraphicNovelStoryboard.tsx — bird's-eye grid with drag-to-reorder, chapter markers, story summary sidebar.
  • Mode selection splash: Auto-generate (Flash 3pg / Short Story 9pg / Novel coming-soon) or Choose Your Own Adventure.
  • Dashboard + stream: graphic novel entries show panel thumbnails instead of raw JSON. Stream cards use first panel as background image.
  • Notebook creation: art style + characters + setting + tone + 🎲 Seed random story (5 genre presets).

Journal Import (Day One, Journey, CSV)

Import for users migrating from other journaling apps. Preserves original timestamps so Odessa can read full imported history.

  • Backend: journal-import.tsPOST /api/import/day-one (ZIP/JSON), /journey (ZIP), /csv (auto-detected columns). Multer + adm-zip. Auto-creates notebook per import. Tracks import_source.
  • Frontend: /dashboard/import — source selector, file picker, success card with "See your story with Odessa →" CTA.
  • Public landing: /see-your-story — "You've been writing for years. Have you ever seen what your journal is trying to tell you?"
  • Navbar: "See Your Story" (public), "📥 Import Journal" in avatar dropdown (signed-in). Footer: Odessa + See Your Story.

The Hum — Aspirational Resonant Energy Device

Concept page for "The Hum" — a personal resonant energy device inspired by the 768-patent analysis. Presents 5 modular components (The Core, The Tuner, The Spark, The Bridge, The Shell) with DALL-E 3 HD photorealistic concept renders. Links each component to the relevant experiment calculator pattern.

  • Landing page: apps/frontend/src/app/hum/page.tsx ��� 5 component cards with images, Tesla connection section, concept render hero, admin-only image prompt overlay.
  • Prototype concept: apps/frontend/src/app/hum/prototype/page.tsx
  • Concept images: apps/frontend/public/images/hum/the-core.png, the-tuner.png, the-spark.png, the-bridge.png, the-shell.png, plus 3 earlier concept renders.
  • Routes: /hum, /hum/prototype

Admin runbook

Super-admin operational reference at docs/operations/ADMIN_RUNBOOK.md. Covers grant/revoke super-admin, health-page interpretation + applying missing migrations, manual + cron competition recompute, sponsor / opportunity / video approval flows (with the curl recipes), template promotion to classroom_safe, kit-waitlist review routine, sponsor-outreach moderation + digest forensics, impersonation + audit log, cron firing verification, common one-off SQL (drops adjustments, role flips, mention backfills), and a "first 5 minutes when something breaks" checklist.

Admin Panel

Super-admin dashboard with users, organizations, teams, imports, settings, conversion rates, analytics, workout-window controls, encouragement-drops, chat config.

  • Frontend: apps/frontend/src/app/dashboard/admin/page.tsx
  • Sub-pages: open-energy · features · kit-waitlist · health · tasks
  • Backend: apps/backend/src/routes/admin.ts
  • Route: /dashboard/admin · /dashboard/admin/open-energy · /dashboard/admin/health · /dashboard/admin/kit-waitlist

Production Health page

Super-admin only. /dashboard/admin/health shows the live deploy state at a glance: backend status (healthy / degraded), DB round-trip latency, Railway commit SHA, Node version, backend uptime ticking once a second, and — most importantly — a migrations diff that compares files in supabase/migrations/ against what supabase_migrations.schema_migrations reports as applied in production. Anything in the repo but not in prod is listed in an amber call-out so you know what to apply before considering a deploy "green."

  • Frontend page: apps/frontend/src/app/dashboard/admin/health/page.tsx
  • Backend deep-health endpoint: GET /api/admin/health in apps/backend/src/routes/admin.ts — super-admin only, runs a real DB query (latency-measured), reads applied migrations via supabase.schema('supabase_migrations').from('schema_migrations'), returns commit SHA + start time + Node version + env.
  • Local-migrations enumeration: GET /api/admin/local-migrations in apps/frontend/src/app/api/admin/local-migrations/route.ts — server-side fs.readdirSync of the repo's supabase/migrations/ dir, returns filenames + version numbers only (no contents).
  • Public uptime endpoint upgraded: GET /health (no auth) now returns { status, timestamp, started_at, commit_sha } so external uptime monitors can verify the deploy without an authenticated round-trip. Setup guide: docs/setup-guides/UPTIME_MONITORING_SETUP.md walks through configuring BetterStack / UptimeRobot.

Admin Impersonation

Super admins can act as another user for debugging. Audit logged.

Super-admin "View as" preview

Visual-only chrome-only preview that flips the navbar + signed-in widgets to match what a different audience tier sees, without changing auth. Distinct from real impersonation above: the backend still sees your super-admin session and data isn't filtered — this is "does the page LOOK right for X?" not "what data would X see?" For the latter, use the real impersonation flow at /dashboard/admin/impersonations.

  • Hook: lib/view-as.tsuseViewAsRole() (reactive, useSyncExternalStore + localStorage + cross-tab storage event sync). Four modes: admin (default, no-op), creator, viewer, anonymous. State key: hj:view-as-role-v1. Companion getEffectiveIdentity({ actualIsSignedIn, actualIsSuperAdmin, viewAsMode }) returns { effectiveIsSuperAdmin, effectiveIsSignedIn, effectiveIsCreator } for chrome decisions.
  • Picker: components/layout/ViewAsPicker.tsx — eye-glyph icon in the navbar with a 4-option dropdown. Active-mode indicator dot + cyan ring when not in admin mode. Mounted conditionally on actualIsSuperAdmin in Navbar.tsx — gated on the actual role (NOT the effective one) so super-admins can always reset back to admin even when previewing as anonymous.
  • Banner: components/layout/ViewAsBanner.tsx — sticky cyan bar at the top of every page when view-as ≠ admin. Says Viewing as <Mode> with a Reset button. Authoritative re-check of the super-admin role on mount so a stale localStorage flag can never expose the banner to non-admins. Mounted via ClientOnlyComponents.tsx alongside the existing ImpersonationBanner.
  • Navbar wiring: split state into actualUser/actualIsSuperAdmin (source of truth from supabase auth + profile row) + user/isSuperAdmin (effective, consumed by the rest of the navbar). A useEffect re-derives effective from actual + viewAsMode on every change. Existing scattered isSuperAdmin && (...) checks throughout the navbar continue to work unchanged — they now read the effective flag automatically.
  • What this DOESN'T do: filter backend data, change which routes you can access (super-admin auth still works), or affect apiRequest behavior. Pages that backend-gate to specific roles (e.g. /studio/* requires is_creator) will still let you in. For full role-switching including backend, use impersonation.

Closed-beta creator onboarding (/studio/welcome)

The 90-second tour for anyone granted Creator capability. Static server component (no auth gating) so it doubles as marketing for the eventual paid Studio tier. Covers: the 6-step pipeline (universe → season → prose → takes → audio → publish), the visibility model (your seasons private by default, flip a toggle to share), recommended first moves (listen to an existing show → write a season in Turing Logs → build your own universe later), and what's NOT enabled yet (direct billing, public discovery for creator shows, custom voice cloning). Linked from /dashboard/admin/seasons's empty-state banner ("New here? The 90-second creator tour →") so creators with zero seasons see it on first arrival.

Creator manuscript editor route (/studio/write) — Workstream A1

The creator-facing on-ramp to the manuscript editor. The editor itself + all its feature slices ship in ProseEditorModal.tsx (TipTap direct editing + BibleReferencePanel at hand + InlineAiToolbar inline AI + StyleExampleModal voice exemplar + "💬 Ask JQ" canon chat, PRs #966–#975); until this route it was only reachable through the admin ChapterTakesModal. Now external creators reach it on their own stories without touching /dashboard/admin. See docs/product/WRITING_EXPERIENCE_A1.md.

  • Writer hub: /studio/write — lists the caller's own seasons via GET /api/story-seasons/mine (auth-only, not super-admin). Empty state → /studio/new. A "Write" nav item is in StudioChrome header + footer.
  • Chapter list: /studio/write/[seasonId]'use client' page (useParams, so no Next-15 params: Promise concern), lists chapters and opens ProseEditorModal per chapter. Refreshes has-prose flags via onSaved. Passes chapters + onNavigate to the modal so a creator can move between chapters in-editor (‹ Chapter N of M ›, flush-saves before switching); the admin ChapterTakesModal caller omits those props so the nav doesn't render there.
  • List endpoint (new): GET /api/story-seasons/:seasonId/editor-chapters in routes/story-seasons.ts — returns { season: {id,title}, chapters: [{episode_id, episode_number, title, has_prose, badges}] }, where badges is { dramatic_role, spine_turn, stakes_intensity (1–10), stakes_summary } — a read-only projection of the stored dramatic_role / chapter_spine / chapter_stakes JSONB via the pure services/chapter-badges.ts deriveChapterBadges (no compute; golden-tested in chapter-badges.test.ts). Gated requireSuperAdmin (accepts is_creator) + assertSeasonProseAccess (own-or-super, same gate as the prose read/write endpoints). Uses :seasonId (not :id) to bypass the router.param('id') gate — same pattern as prose-assist / style-example.
  • Chapter management (owner-gated, no migration): the /studio/write/[seasonId] page can add / rename / reorder / delete chapters. All in routes/story-seasons.ts, gated requireSuperAdmin + assertSeasonProseAccess: POST /:seasonId/chapters (append a blank chapter at max(episode_number)+1, set triggered:true, visibility:'public', chapter_prose:'' to match the manuscript-import convention), PATCH /episodes/:episodeId/chapter-meta (rename title), DELETE /episodes/:episodeId/chapter (delete + renumber the tail down one, ascending so it never trips unique(season_id, episode_number); FK children are all on delete cascade/set null so takes/prose_versions/audio_takes clean up on their own; best-effort rebuildSegmentsFromCacheLocal), PATCH /:seasonId/chapters/order (body { ordered_episode_ids } = a permutation of the season's chapters; two-pass renumber — park at 100000+i, then 1..N). Frontend rows have ▲▼ reorder, inline rename, Edit, and 🗑 delete; "+ Add chapter" jumps straight into the new chapter's editor.
  • Ownership: enforced entirely backend-side — no migration needed (story_seasons.owner_user_id migration 134 + the prose endpoints' own-or-super gates already exist). A non-owner creator gets a 403 on the list endpoint, all mutations, and the modal's load.
  • Ask JQ chapter grounding + creator unlock (migration 447): canon mode ("💬 Ask JQ") was super-admin-only; it's now own-or-super, so a creator gets JQ grounded in their own story — canon read, the canon edit/health tools, AND the open chapter's live prose injected into context. The "Ask JQ" button (ProseEditorModal) now sends episodeId on the jq:open-canon event → Chatbot stores canon_episode_id on the conversation (chat.ts create). canon-mode.ts buildSeasonCanon injects the chapter prose (capped 8k, placed high so it survives the 14k canon cap; validated ep.season_id === seasonId so a stale/mismatched id can't leak another story's prose), and a new canAccessCanonScope (own-or-super for season scope, super-only for universe) gates canon at all three seams — conversation create, generateChatResponse, and executeCanonTool. Universe canon stays super-admin-only.
  • Composer environment (A1a-env, PR #979): ProseEditorModal has debounced autosave (1.5s idle, client-side unchanged check to skip redundant PATCH/version snapshots, flush-on-close, ⌘/Ctrl-S force-save) + a save-status pill, a distraction-free focus mode (hides bible + Voice/Ask JQ/Bible chrome, centered measure, Esc to exit), and serif/sans + A−/A+ size controls persisted in localStorage (proseEditor.*). No backend change — reuses the existing PATCH prose endpoint.

Per-episode tipping ($1 / $5 / $10) — Phase 1 Step 6

Closes the last deferred Phase 1 line. Lower-friction revenue capture than the $5/mo subscription: a listener loved one chapter, throws a buck. Anonymous tippers welcome (Stripe Checkout collects the email).

  • Schema: migration 138_episode_tips.sql adds episode_tips (id, user_id nullable, email, season_id, episode_number, amount_cents, message, stripe_checkout_session_id unique, stripe_payment_intent_id, status enum, created_at, paid_at). RLS: super-admins read everything; tippers read their own rows; backend writes via service role.
  • Service: services/episode-tips.tscreateTipCheckout(opts) inserts a pending row, builds a Stripe Checkout session in payment mode (one-time, not subscription) using inline price_data.unit_amount so we don't have to pre-create Stripe prices for $1/$5/$10. Metadata carries kind: 'episode_tip', tip_id, season_id, episode_number, amount_cents — enough for the webhook to attribute without a separate query. recordTipPayment(sessionId, paymentIntentId, status) flips pendingpaid on checkout.session.completed. Idempotent (skips already-final rows).
  • Route: POST /api/payment/tip-episode (auth optional via maybeAuthenticateUser) — accepts { season_id, episode_number, amount_cents, message? }, returns { checkout_url, session_id, tip_id }. Allowed amounts hardcoded: $1 / $5 / $10. Success / cancel URLs default to /seasons/[id]?tipped=1#chapter-N and /seasons/[id]?tip_canceled=1#chapter-N.
  • Webhook: extended checkout.session.completed handler in routes/payment.ts — branches on session.mode === 'payment' && metadata.kind === 'episode_tip' BEFORE the existing subscription-handling code. Fires episode_tip_paid PostHog server event (distinct from subscription events so funnel reports don't conflate the two flows).
  • Frontend: components/seasons/TipChapterModal.tsx renders three preset amount cards + an optional one-line note, redirects to Stripe Checkout on submit. No custom-amount in v1 (keeps the picker fast). ChapterPlaylistPlayer gets a 💸 Tip pill in the now-playing strip next to the existing letters / admin pills — opens the modal scoped to the currently-playing chapter. Self-contained; parent doesn't thread state.
  • Success toast in SeasonClient.tsx — when the listener returns from Stripe Checkout with ?tipped=1, a thank-you toast appears (bottom-right, amber gradient) and the query param is stripped via history.replaceState. One-shot per query-param hit.
  • PostHog events: episode_tip_started (frontend, when the user clicks Tip $N inside the modal); episode_tip_paid (server, on webhook).
  • End-of-chapter tip prompt (components/seasons/EndOfChapterTipPrompt.tsx + wired into ReadClient.tsx onEnded handler): floating prompt that appears the moment a chapter audio finishes — the highest-intent moment for converting a listener into a tipper. Distinct from the always-visible inline tip CTA in the prose footer; this surfaces at the exact end of audio so listeners who don't scroll past the audio bar still see it. One prompt per chapter per session (promptedEpisodesRef Set); auto-dismisses after 25s; dismissed when listener taps play on a different chapter (auto-advance handles the engaged-elsewhere case). Two CTAs: "Tip in coins" opens CoinTipModal (no checkout round-trip — best UX, requires drift_enabled + signed-in); "Tip the team" opens TipChapterModal Stripe flow. Gated by tippingEnabled site setting + suppressed when a tip modal is already open.

Gift Graphene+ (12-month one-shot)

$60 buys 12 months of Graphene+ for someone else. Pre-paid sub credit, NOT a Stripe subscription — buyer pays once via Checkout payment-mode, recipient gets a redemption email, on redemption we stamp expires_at = now + 365d. After 12 months, access ends naturally with no auto-renew. Anonymous-friendly (buyer doesn't need an account; recipient signs in to redeem).

  • Schema: migration 209 creates graphene_plus_gifts (purchaser_user_id nullable + purchaser_email; recipient_email + recipient_name + recipient_user_id; personal_message; amount_cents default 6000; stripe_checkout_session_id unique; stripe_payment_intent_id; status enum pending/paid/redeemed/refunded/expired; redemption_code unique 32-byte url-safe token; paid_at/redeemed_at/expires_at). RLS: buyer + recipient + super_admin read; backend service role writes.
  • Service: services/graphene-plus-gifts.tscreateGiftCheckout() mints a code + inserts pending row + creates Checkout session in payment-mode with metadata {kind: 'graphene_plus_gift', gift_id, recipient_email, purchaser_user_id}. recordGiftPayment() (called from webhook) flips pending → paid + fires sendRecipientEmail() (Resend; html + text body; redemption link to /redeem-graphene-plus/<code>). getGiftByCode() returns the public-safe shape for the redeem page (no Stripe ids, no internal fields). redeemGift({code, redeemerUserId}) flips paid → redeemed atomically (race-condition guard via .eq('status', 'paid') filter on the update), stamps recipient_user_id + redeemed_at + expires_at = now + 365d. Idempotent for the original redeemer; rejects re-claim by a different user. hasActiveGiftEntitlement(userId) powers the entitlement check.
  • Subscriber-check integration: services/graphene-subscription.ts isGrapheneSubscriber() is extended to OR in hasActiveGiftEntitlement() after the existing subscription check returns false. Lets every paywall + RSS + Drift pricing path stay agnostic of "subscription vs. gift" — they ask one question (is this user a subscriber?) and get one answer.
  • Webhook: routes/payment.ts checkout.session.completed handler branches on session.mode === 'payment' && metadata.kind === 'graphene_plus_gift' BEFORE the subscription branch. Calls recordGiftPayment + fires graphene_plus_gift_paid PostHog server event (distinct from graphene_plus_subscription_active so the funnel separates gift revenue from sub revenue).
  • Routes: POST /api/payment/gift/checkout (auth optional via maybeAuthenticateUser — anonymous buyers welcome), GET /api/payment/gift/:code (public lookup), POST /api/payment/gift/:code/redeem (auth required).
  • Frontend: /gift-graphene-plus is the buy page (recipient email + optional name + 500-char message + buyer email if not signed in → Stripe Checkout). /redeem-graphene-plus/[code] is the recipient redeem page — handles invalid code, pending payment, refunded gift, already-redeemed, paid-but-not-signed-in (sign-in CTA with continue param), paid-and-signed-in (big "Redeem 12 months" button → 365-day window starts now).
  • Email: graphene-plus-gifts.ts sendRecipientEmail() sends a styled HTML + text email via Resend. Subject: <purchaser_email> sent you 12 months of Graphene+. Body shows the personal note (escaped) + a redeem button + "no auto-renew" reassurance. Fails silently — payment must never be blocked by an email outage.
  • PostHog events: graphene_plus_gift_paid (server, on webhook).
  • Buyer post-purchase visibility: /dashboard/my-gifts lists gifts the calling user purchased with status badges (Pending / Awaiting redemption / Redeemed / Refunded / Expired), per-row actions (✉ Resend email + 🔗 Copy redemption link with clipboard fallback), and stat cards (total / redeemed / awaiting). Backend listSentGifts({userId, email}) merges by both purchaser_user_id and lowercased purchaser_email so anonymous buyers who later signed up see their full history. resendRedemptionEmail({giftId}) re-fires the Resend email; only valid for status='paid' gifts. Routes: GET /api/payment/gifts/sent (auth) + POST /api/payment/gifts/:id/resend (auth + ownership check).
  • Recipient subscription page: /dashboard/subscription renders a distinct purple "🎁 Gift active" card when entitlement_source === 'gift' instead of the standard "Manage billing" card. Shows purchaser email + expiration + a "Subscribe to extend" CTA. Backend getActiveGiftEntitlement(userId) returns the latest-expiring redeemed gift (handles stacked-gifts edge case); the GET /graphene-plus/status endpoint surfaces entitlement_source ('subscription' | 'gift' | null) + gift: { expires_at, redeemed_at, purchaser_email }.
  • Pre-expiry reminders: migration 210 adds reminder_30d_sent_at, reminder_7d_sent_at, reminder_1d_sent_at to graphene_plus_gifts + partial unique indexes for the cron's range scan. sendGiftPreExpiryReminders() (in graphene-plus-gifts.ts) sweeps gifts expiring in 28-32d, 6-8d, and 0-2d windows and sends "Your Graphene+ gift ends in N days" emails (urgency-tinted accent color: low → purple, medium → amber, high → red). Subscribe-to-extend CTA goes to /graphene-plus?source=gift_reminder_<window> for attribution. Cron registered in apps/backend/src/index.ts at 1h cadence (heartbeat name gift_pre_expiry_reminder_tick); per-gift failures land in errors[] so a single bounce doesn't block the rest of the queue, and the heartbeat status flips to partial when any error occurs. Stamp-after-success ordering means a delivery failure leaves the gift eligible for retry on the next cron run while still in the multi-day window.

Timelines mode (branching seasons with 4 endings)

A new sibling story mode alongside journal and novel. Listeners walk one of 4 endings driven by 2 binary choices. Brand-name "Timelines"; each path is a distinct timeline that the listener walks. Characters in the chosen timeline have dreams of events on the unchosen timelines — those dreams are the cross-trellis payoff: when a listener rewinds and walks a different path, they realize the dreams from path A were the actual events of path B.

Trellis structure (12 unique chapters total per season, each reader walks 7):

  • shared (Ch1-2) — every reader

  • A / B (Ch3-4) — after choice 1

  • AA / AB / BA / BB (Ch5-7) — after choice 2 (one ending per leaf)

  • Schema: migration 208 updates the story_seasons.mode CHECK to allow 'timelines', adds path_key text to story_episodes with a per-path uniqueness index (multiple chapters can share an episode_number across paths), and creates three tables:

    • season_timeline_choices — exactly 3 rows per season: choice 1 (parent_path_key=NULL, applies globally) + choice 2 with parent_path_key='A' (dilemma framing for readers who chose A on choice 1) + choice 2 with parent_path_key='B'. Partial unique indexes enforce the row count.
    • season_timeline_beats — the per-path beat sheet that drives drama-parity engineering. Each beat carries beat_type (inciting/rising/midpoint/climax/denouement/transition), stake_level (1-10), tension_type (moral/physical/emotional/existential), and an optional dream_of_path + dream_seed payload. RLS hides beats from the public — they contain spoilers (climax stakes, dream-seed for unwalked paths). Backend reads via service role.
    • reader_timeline_state — per-reader state (which choices they've made, which path they're on, which leaves they've completed). Anonymous-friendly: anon_id stored in localStorage and sent in X-Anon-Id header; signed-in readers get cross-device state via user_id. Partial unique indexes per identity column.
  • Service: services/timeline-seasons.tsgenerateTimelinePlan() runs one big GPT call to produce the entire scaffold (cast summary + setting + 3 choice rows + 7 paths × beat sheets + 12 chapter outlines + dream-seed matrix), validates the plan against the canonical Trellis layout (paths + chapter ranges), and persists choices/beats/episodes idempotently (delete-then-insert at the season level so regen replaces cleanly). generateTimelineChapter(seasonId, pathKey, episodeNumber) mirrors the novel-chapter generator pattern but threads the path's beat brief + an optional dream block when this chapter has a dream_of_path tag — the prompt instructs the model to render the dream as 1-2 paragraphs of character-interiority that listeners on a later walk recognize as the actual climax of the dreamed-of path. scoreTimelineParity(seasonId) returns per-leaf metrics (total stake, climax stake, tension types) and flags leaves that are >3 below the strongest or have a climax <7. pathKeyFromChoices() is the canonical (c1, c2) → path_key map. getOrCreateReaderState, setReaderChoice, rewindReaderToChoice, markPathCompleted cover the per-reader lifecycle.

  • Routes: in routes/story-seasons.ts:

    • Admin (super-admin): POST /:id/timelines/plan, POST /:id/timelines/chapter, GET /:id/timelines/parity.
    • Public (auth optional, anon supported via X-Anon-Id header): GET /:id/timelines/structure (choices + chapter list, beats omitted), GET /:id/timelines/state, POST /:id/timelines/choice, POST /:id/timelines/rewind, POST /:id/timelines/complete-path.
    • "What others chose" reader reveal (public, aggregate-only): GET /:id/timelines/choice-stats{ stats: { choice_1: {A,B,total}, choice_2: { A: {A,B,total}, B: {A,B,total} }, total_readers } }. getTimelineChoiceStats fetches reader_timeline_state.choice_1/choice_2 and calls the pure aggregateChoiceStats(rows) (golden-tested timeline-choice-stats.test.ts) — counts only, no per-reader data, so it's safe to expose (vs. the super-admin /analytics funnel). choice_2 is split BY PARENT branch (the choice_1 the reader was on), which /analytics doesn't do. TimelineClient fetches it (refetched when the reader picks, so their own vote counts) and renders a ChoiceResult bar pair AFTER each committed choice (never before — no biasing), the reader's own pick highlighted "Your pick"; total<=1 shows "you're the first". Modeled on the cafe_battle_picks crowd-split pattern; the same endpoint feeds the VR branching room (Phase 3, GRAPHENE_VR_STORY_PLAYER.md).
  • Reader UI: /seasons/[id]/timeline — dedicated reader page (separate from /read because the path-aware, choice-gated flow is fundamentally different). On mount: mints/reads timelines_anon_id_v1 from localStorage, fetches structure + state in parallel. Renders ancestor-chain chapters only (e.g. on path AA, shows shared Ch1-2 + A Ch3-4 + AA Ch5-7), surfaces the next pending choice as a card between sections, ending screen with "mark this timeline finished" + rewind controls (re-pick choice 2 OR start over from choice 1). "X of 4 timelines walked" indicator powered by completed_paths. The first-visit panel tells listeners up-front there are 4 endings + that the dreams they hear along the way are other timelines.

  • Admin: /dashboard/admin/seasons — third radio in the create-season mode picker (🌀 Timelines (4 endings)). Selecting it skips persona casting + universe scoping in the create call; the backend generateSeasonPlan short-circuits to "create the season row only", deferring branching plan generation to a follow-up. Per-season action buttons render only on mode='timelines' rows: 🌀 Plan Timelines (one GPT call → choices + beats + 12 chapter scaffolds; idempotent — wipes prior plan first), ✍️ All Chapters (walks all 7 paths and POSTs each chapter prose request sequentially — 12 GPT calls). Existing per-chapter audio render pipeline picks up timelines chapters automatically since they're just story_episodes rows.

  • Drama parity engineering: enforced at three layers. Plan prompt explicitly says "all 4 ending paths must have stake totals within 3 points of each other, climaxes >=8, at least 3 different tension types across the leaves" and the model commits to those numbers up-front. Schema stores stake_level + tension_type so they're queryable, not vibes. Critic (scoreTimelineParity) flags weak leaves; regenWeakLeaves() then re-prompts GPT for just the weak leaves with the strongest leaf as parity anchor — preserves chapter prose for the strong leaves and the choice rows so admins iterate parity without re-rolling the show. The admin parity panel (per-row in the admin seasons list, opens via ⚖️ Parity button) renders 4 stake bars + climax-stake values + tension-type badges, with 🔁 Regen weak leaves enabled when any leaf is flagged. The dream system sits on top: every leaf path dreams of at least one other leaf, spreading coverage across the trellis.

  • Cost: 12 chapter prose calls + 1 plan call ≈ a few cents on gpt-4o-mini per season. Audio render is the same per-chapter ElevenLabs pipeline ($3-5/full-trellis at Creator rates if all 12 chapters get rendered; lazy render is feasible by deferring leaf-path audio until the first walker reaches each).

  • Audio render: services/timeline-audio.tsrenderTimelineChapterAudio({seasonId, pathKey, episodeNumber}) is the path-aware analog of novel-mode's renderChapterAudio. Reads chapter prose (with path_key match — Ch5 exists on AA/AB/BA/BB simultaneously), chunks via chunkProseIntoSegments, TTSes each chunk with the season's narrator voice (falls back to DEFAULT_NARRATOR_VOICE), stitches with ffmpeg, uploads to audio/{seasonId}/timelines/{path_key}/ep-N.mp3 (path_key in the storage prefix so leaves don't collide), stamps story_episodes.chapter_audio_url + duration + bytes + rendered_at + free_until window. renderAllTimelineAudios({seasonId, force?}) batches all 12 chapters in canonical path order (shared → A/B → leaves) and tolerates per-chapter errors (collected in errors[] so a single TTS hiccup doesn't lose progress). Skips chapters that already have audio unless force=true. Returns { rendered, skipped, errors, total_cost_usd_estimate }. Endpoints: POST /:id/timelines/render-chapter-audio (one chapter), POST /:id/timelines/render-all-audio (batch). Admin button: 🎙️ All Audio in the per-row timelines action group.

  • Per-leaf poster art: migration 211 creates season_timeline_posters (one row per (season_id, path_key) for path_key in {AA,AB,BA,BB}). services/timeline-posters.ts generateLeafPosters({seasonId}) reads each leaf's climax beat (description + tension_type + stake_level) and feeds it into a leaf-specific DALL-E prompt, so the four ending posters reflect their distinct tonal verdicts (moral / physical / emotional / existential climaxes get different visual treatments via tensionMood mapping). UPSERT on (season_id, path_key) makes re-runs cleanly replace prior posters. Cost: ~$0.32 per Timelines season at DALL-E 3 HD pricing. Route: POST /:id/timelines/render-leaf-posters (super-admin). Reader UI: TimelineClient shows the leaf poster as a hero banner above the page header once the listener is on a leaf path (length-2 current_path_key), with a gradient fade into the page background + a path-key pill. Shared / A / B paths show no hero (those use the season's main poster on /graphene). The /timelines/structure endpoint includes leaf_posters: { AA?: url, AB?: url, ... } so the reader can swap in the right image client-side without an extra fetch. Admin button: 🎨 Leaf Posters in the per-row timelines action group.

  • Novel → Timelines converter: services/novel-to-timelines.ts convertNovelToTimelines({sourceSeasonId}) clones a novel-mode season into a brand-new timelines-mode variant — preserving identity (poster, narrator voice, narrator persona, universe, genre, setting, owner_user_id) and using the source's chapter prose as adaptation inspiration. Original season stays untouched; the variant lives as a sibling entry. Title defaults to <Source title> · Timelines. Status starts draft so the conversion doesn't accidentally surface the variant on /graphene before chapters are filled in. The branching plan is generated by generateTimelinePlan with a new inspiredBySeasonId parameter — when set, the prompt includes the source's chapter summaries (titled + 800-char prose excerpts) and explicit adaptation rules: AA targets the closest faithful re-rendering of the source's ending; AB/BA/BB diverge into new tonal verdicts; choices land at moments that already exist as natural inflection points in the source. Cleanup-on-failure: if the plan call throws, the freshly-created variant row gets deleted to avoid orphaning empty timelines seasons. Route: POST /api/story-seasons/:id/convert-to-timelines (super-admin). Admin: 🌀 Convert to Timelines button on novel-mode rows (purple→fuchsia gradient). Post-conversion the admin still runs ✍️ All Chapters / 🎙️ All Audio / 🎨 Leaf Posters before publishing — same workflow as a fresh Timelines season.

Subscriber-only RSS feed (Graphene+ back-catalog access in podcast clients)

The public /api/podcast/rss drops chapters past their 7-day free window — Graphene+ subscribers paying $5/mo previously had to use the web player to hear back-catalog content. Migration 137_subscriber_rss_token.sql + a new authenticated feed endpoint close that gap. Each subscriber gets a stable, private feed URL they can paste into Apple Podcasts / Pocket Casts / Overcast / etc. and get the full catalog.

  • Schema: subscriptions.rss_token text (nullable; populated on first request) + a unique partial index. Token is 32 random url-safe bytes from crypto.randomBytes(32).toString('base64url'). Stored on the subscription row so deleting the sub naturally invalidates the token; status='canceled' or grace-period-expired stops it working too.
  • Service: services/subscriber-rss.tsensureSubscriberRssToken(userId) (returns existing or issues new) + resolveSubscriberFromToken(token) (reverse lookup with status / grace-period checks). No HMAC/JWT machinery — the database row IS the proof.
  • RSS builder: buildPodcastRss(siteUrl, { forSubscriber: true }) in season-distribution.ts skips the chapter_free_until filter, suffixes the channel title with "(Subscriber feed)", and adds <itunes:block>Yes</itunes:block> so directories don't index it (each URL contains a private token).
  • Routes: GET /api/podcast/rss/subscriber/:token (public — token IS the auth) returns the subscriber feed; Cache-Control: private, max-age=300. Returns 404 (not 401) on bad/inactive tokens since podcast clients handle 401 poorly. GET /api/payment/graphene-plus/rss-token (authenticated) returns the calling user's token, issuing one on first request; 404 when not subscribed.
  • UI: /dashboard/subscription Graphene+ card gets a "🎧 Your private podcast feed" section when active — read-only URL + copy button + brief instructions for Apple Podcasts ("Follow a Show by URL") and Pocket Casts/Overcast ("add by URL"). Notes that Spotify doesn't support custom RSS.

Story Model History admin page (/dashboard/admin/story-model-history)

Super-admin timeline of every platform version — each entry shows the version label, release date, default model, default critic set (chips), and the human-readable notes for what shipped. Adds and removes vs the prior version are computed client-side and badged (green = added critic, struck-through red = removed). The current version gets a "Current" badge. Source of truth: services/platform-versions.ts — append-only registry stamped on every chapter take. New endpoint: GET /api/story-seasons/platform-versions (super-admin).

Linked from /dashboard/admin under Content & critique alongside Critic effectiveness — those two pages together answer "which pipeline produced this prose, and which critics are doing the work in it?"

Chapter Character Beats — the sixth planning layer (SHIPPED)

Per-chapter, per-character mini-arcs. For each MAJOR character present in a chapter, a beat = { character_key, name, entry_state, shift, exit_state } — where they enter the chapter, what shifts, where they end — so secondary characters MOVE instead of just appearing (the multi-character-flatness failure mode). Completes the planning stack alongside the bible + knowledge layer (v1.5) + spine (v1.6) + stakes (v1.6) + dramatic role (v1.7).

Migration 440_chapter_character_beats.sql adds two columns to story_episodes: chapter_character_beats jsonb ({ characters: [{ character_key, name, entry_state, shift, exit_state }] }) + chapter_character_beats_generated_at timestamptz.

Service services/chapter-character-beats.ts mirrors chapter-stakes.ts exactly: computeChapterCharacterBeatsForSeason(seasonId) makes ONE PLANNING_MODEL_KEY pass over the full bible + every chapter outline, so each character's arc is CONTINUOUS across chapters (a chapter's exit_state flows into the next chapter's entry_state); it filters the bible to major characters (not departed / introduced-only), passes the roster + keys, and drops hallucinated keys on parse. hasChapterCharacterBeats(seasonId) powers lazy-compute (first chapter prose gen per season); buildChapterCharacterBeatsBlock(beats) renders the prompt block (returns "" for legacy/empty chapters so the five-layer prompt is unaffected) — golden-tested (chapter-character-beats.test.ts). Injected into season-novel-chapter.ts callChapterPromptGPT right below the stakes section (both prose-gen call sites). Admin: POST /api/story-seasons/:id/regenerate-chapter-character-beats (super-admin) + a 🎭 Regen character beats button in EpisodesAdminModal.tsx beside the spine/stakes ones. ~$0.05/season (amortized across every downstream chapter). Design note: plans/chapter-character-beats.md.

Delivery Auditor critic — prose-vs-plan enforcement (v1.8)

Closes the loop on the v1.5/v1.6/v1.7 planning layers. Until v1.8 the planning suggested but nothing checked: we told the writer to open on a specific image, turn at a specific beat, end on a specific image, with these stakes — but no critic verified the prose actually delivered. AI prose under temp 0.85 drifts; v1.8 makes the layers enforced.

New critic in services/script-critics.ts:

  • delivery_auditor — compares prose to plan. Flags: planned reveals missing or premature, chapter doesn't open on the planned opening_image, central_turn never lands on the page, closing_image replaced by a thematic statement, stakes (wants/obstacle/cost) not dramatized.

Calibrated NOT to penalize organic improvements: if the chapter found a stronger image than the plan, that's craft and gets noted positively in the summary, not flagged. Plan is a compass, not a straitjacket.

New flag on the critic interface: needs_chapter_plan: true. The critique runner in episode-take-critiques.ts loads the chapter's reader_knows_entering / chapter_reveals / still_withheld / chapter_spine / chapter_stakes directly from the story_episodes row and injects them as a "PLANNED FOR THIS CHAPTER" block in the user prompt alongside the prose. Cheap (single SELECT against the same row that's already loaded for the take).

Added to DEFAULT_CRITIC_KEYS so every auto-refine pass now includes a delivery check. Platform version bumped to v1.8.

Per-chapter stakes — what's at risk (v1.7)

Completes the planning triad alongside knowledge layer (v1.5) and spine (v1.6):

  • Knowledge (intellect) = what the reader learns
  • Spine (sense) = where the camera lands
  • Stakes (emotion) = what's at risk RIGHT NOW

Why this gap mattered: the bible has the season's overall arc, but no layer told the writer what's at risk for the central character IN THIS CHAPTER. Without explicit per-chapter pressure, AI prose drifts into low-stakes description — chapters move pieces around without urgency, and a reader can't articulate what was at risk by the end.

Migration 142_chapter_stakes.sql adds two columns to story_episodes:

  • chapter_stakes jsonb{ wants, obstacle, cost }
  • chapter_stakes_generated_at timestamptz

Service services/chapter-stakes.tscomputeChapterStakesForSeason(seasonId) makes one GPT-4o-mini pass over the full bible + every chapter outline at once, ESCALATING pressure across chapters (early chapters establish what's at risk; middle chapters raise the price of failure; final chapters land the cost or its inversion). Persists JSONB. hasChapterStakes(seasonId) powers the lazy-compute. buildChapterStakesBlock(stakes) renders the prompt block; returns "" when fields empty so legacy seasons fall back gracefully.

Wiring in season-novel-chapter.ts: both generateChapterRaw and generateChapter lazy-compute on first run and inject the block AFTER the spine in the system prompt — order: bible → reader window → scene compass → pressure → framework directive.

The injected block frames stakes as scene-level pressure to dramatize, NEVER to state on the page: "Do NOT have a character state the stakes ('if I don't do this, I'll lose everything') — that's on-the-nose. Stakes live in WHAT IS RISKED BY EACH SPECIFIC CHOICE the character makes."

Route: POST /api/story-seasons/:id/regenerate-chapter-stakes (super-admin) for manual recompute. UI: 🎯 Regen chapter stakes button next to the v1.5 🧭 and v1.6 📐 buttons in EpisodesAdminModal toolbar. Cost ~$0.005/season, independent of per-chapter prose generation. Platform version bumped to v1.7.

Per-chapter spine — scene shape (v1.6)

The bible says what the world is. The knowledge layer (v1.5) says what the reader is allowed to learn this chapter. The framework directive (three_act etc.) says what the chapter does at the act level. Nothing told the writer where the chapter physically begins, pivots, and ends as a scene — and that gap showed up as descriptive throat-clearing openings, thematic-summary endings, and middles that meandered without a turn.

v1.6 adds a third planning layer alongside bible + knowledge layer: a 3-beat scene compass per chapter.

Migration 141_chapter_spine.sql adds two columns to story_episodes:

  • chapter_spine jsonb{ opening_image, central_turn, closing_image }
  • chapter_spine_generated_at timestamptz

JSONB instead of three text columns so the schema can grow (POV anchor, tonal register, etc.) without further migrations.

Service services/chapter-spine.tscomputeChapterSpineForSeason(seasonId) makes one GPT-4o-mini pass over the full bible + every chapter outline at once, contrasting scene shapes across chapters (so chapters don't all open the same way or close on the same beat). Persists JSONB. hasChapterSpine(seasonId) powers the lazy-compute path. buildChapterSpineBlock(spine) renders the prompt block; returns "" when fields empty so legacy seasons gracefully fall back.

Wiring in season-novel-chapter.ts: both generateChapterRaw and generateChapter lazy-compute the spine on first chapter run and inject the block AFTER the knowledge layer in the system prompt — so the chapter writer sees: bible → reader window → scene compass → framework directive.

The injected prompt frames the beats as compass points, not lines to copy: "Realize them as prose; do not paste them as text. Hold the chapter to this shape: open in concrete sensory image, turn at the midpoint, close on a lingering image."

Route: POST /api/story-seasons/:id/regenerate-chapter-spine (super-admin) for manual recompute. UI: 📐 Regen chapter spine button next to the v1.5 🧭 button in EpisodesAdminModal toolbar. Cost ~$0.005/season — independent of per-chapter prose generation. Platform version bumped to v1.6.

Per-chapter knowledge layer — what the narrator knows vs. what the reader is allowed to know (v1.5)

Same root cause as v1.4 (the model sees the whole bible and over-explains because it knows everything), addressed upstream instead of downstream. Where v1.4 added the Subtext Doctor critic to flag bible-leak / on-the-nose foreshadowing / premature resolution after the fact, v1.5 hands the chapter writer explicit per-chapter constraints up front so those slips never make it onto the page.

Migration 139_chapter_knowledge_layer.sql adds four columns to story_episodes:

  • reader_knows_entering — what the reader has been told as of the end of the previous chapter
  • chapter_reveals — what THIS chapter is allowed to land (1–2 specific things)
  • still_withheld — canon the writer KNOWS but the chapter must NOT touch yet
  • knowledge_layer_generated_at — timestamp for staleness signals

Service services/knowledge-layer.tscomputeKnowledgeLayerForSeason(seasonId) makes one GPT-4o-mini pass over the full bible + every chapter outline at once, balancing pacing of reveals across the whole novel rather than locally per chapter. Persists results to the four columns. hasKnowledgeLayer(seasonId) tells the lazy-compute path whether to fire on first chapter generation. buildKnowledgeLayerBlock(fields) renders the prompt block injected into the chapter writer's system prompt; returns "" when fields are empty so legacy seasons gracefully fall back to the v1.4 prompt structure.

Wiring in season-novel-chapter.ts: both generateChapterRaw (takes path) and generateChapter (legacy path) check the chapter row for the three knowledge fields, lazy-compute the season's layer if missing, re-pull the row, build the block, and pass it to callChapterPromptGPT — which inserts it between ${bibleSection} and ${directiveBlock} in the system prompt. The bible defines what the narrator knows; the knowledge layer defines the reader's window.

Route: POST /api/story-seasons/:id/regenerate-knowledge-layer (super-admin) for manual recompute after meaningful bible changes. UI: 🧭 Regen knowledge layer button in EpisodesAdminModal toolbar (novel mode). Cost ~$0.005/season — independent of per-chapter prose generation cost. Platform version bumped to v1.5.

Subtext Doctor critic + tightened chapter prompt (v1.4)

Triggered by a reader observation: "Chapter 1 has vagueness and tell-don't-show — like the algorithm packs too much in case it's the last chapter, gives away foreshadowing too easily." Diagnosis: the model can see the entire bible (secret_knowledge, planted_threads, character arcs), knows the destination, and over-explains because it's trying to demonstrate it knows. v1.4 pushes back on two fronts:

1. New subtext_doctor critic in script-critics.ts — uses cross-chapter context (bible + prior chapters) to flag:

  • Named emotion instead of shown ("she was nervous" → suggest a body / gesture / action substitute)
  • Explained motivation (lines that tell WHY a character acts; the action should carry the motive)
  • On-the-nose foreshadowing (sentences whose only function is gesturing at a future reveal — "little did she know")
  • Premature resolution (statements that close a question the chapter or season was building)
  • Bible-leak (canon surfaced without dramatic context — secret_knowledge slipped in unprovoked)
  • Too many beats (a chapter racing through three plot turns instead of dwelling in one)
  • Narrator over-narration (narrator stepping in to summarize / interpret what just happened)

Calibrated NOT to flag intentional ambiguity, on-page dialogue explanation, or sensory specificity (the Specifics Sharpener handles those calls). Added to DEFAULT_CRITIC_KEYS so it runs in every auto-refine pass.

2. Tightened chapter prompt in season-novel-chapter.ts:

  • Explicit "this chapter is ONE BEAT, not a miniature complete novel"
  • For non-final chapters: "DO NOT resolve the season's central tension. N chapters remain after this one — leave them work to do. Resist on-the-nose foreshadowing."
  • "Show, don't tell. 'Her hand shook' beats 'she was nervous.'"
  • "You know the bible's secret_knowledge and planted_threads. The reader DOES NOT, and shouldn't yet — except for what's earned this chapter. Withhold what doesn't need to land here."
  • "If you have an instinct to explain a character's motivation: don't. Trust the reader to assemble it from what they see."
  • "One question opens this chapter; one question (the next chapter's hook) closes it. Resist closing your own question prematurely."

Platform version bumped to v1.4. Existing chapters now show as behind in the version dashboard — improve them through the new lens via the bulk-improve workflow on /dashboard/admin/seasons.

First Reader critic — cognitive/emotional friction

Different angle from the craft critics — pretends to be a casual first-time reader and reports moment-to-moment friction: pronoun confusion, lost-the-thread passages, re-read tax, zoning-out drift, setup overload, tone whiplash. Doesn't critique craft (other critics handle that); reports its own reading experience. Catches what tension/dialogue/specifics critics miss because they're too close to the text. Added to DEFAULT_CRITIC_KEYS; bumped platform to v1.3 in platform-versions.ts.

Critic effectiveness meta-analysis (/dashboard/admin/critic-effectiveness)

Read-only aggregation over episode_take_critiques + episode_critique_decisions (and the season-level equivalents). Per-critic metrics: run count, finding count, avg findings per run, decision rate (% of findings with any verdict), apply rate (% of decisions that were apply/override), total cost + per-run cost, severity distribution.

  • Service: services/critic-effectiveness.ts — pulls every critique row + every decision row in two queries each, builds a composite finding_id → action map for O(1) lookup, hydrates with the registry from script-critics.ts so the UI gets labels + specialty descriptions for free. Critics in the registry that have never run still surface (zero-state rows) so admins know what's available to enable.
  • Route: GET /api/admin/critic-effectiveness (super-admin).
  • UI: 4-column metrics table per critic, sortable by run count / findings / apply rate / cost. Apply rate is tone-coded: ≥50% emerald, 25–50% amber, <25% red — surfaces over-eager critics at a glance. Linked from /dashboard/admin as 📊 Critic effectiveness.
  • No retention/recurrence metric in v1 (would require diffing prose between takes, meaningfully more work). Decision + apply rates are the highest-signal v1 metrics.

Studio billing dashboards (/dashboard/admin/studio-billing + per-creator drill-down)

Per-creator TTS + LLM spend, rolling 30-day window. Companion to /studio/billing (creator self-service). Sister to /dashboard/admin/llm-spend (platform-wide LLM stats) and /dashboard/admin/creator-usage (legacy take/critique cost view). Foundation for Phase 2 pricing per docs/product/EMBERKILN_ROLLOUT.md.

  • Service: services/studio-billing.tsgetBillingForUser(userId) (caller's own snapshot, drives /studio/billing), listBillingForAllCreators() (admin overview, sorted by total spend desc), getBillingDetailForUser(userId) (per-creator drill-down with daily series + top-N expensive calls + per-season + per-LLM-feature breakdowns). PRICING_NOTES is the single source of truth for the dashboard footer math attribution.
  • Routes (studio.ts): GET /api/studio/billing (auth, caller's own), GET /api/studio/admin/billing (super-admin, overview), GET /api/studio/admin/billing/:userId (super-admin, drill-down).
  • UI: overview table sortable by total / TTS / LLM / quota %; creator names link through to /dashboard/admin/studio-billing/[userId] — headline counters + daily stacked bar chart (TTS emerald, LLM purple) + top-25 most expensive calls TTS+LLM interleaved + per-season totals + per-LLM-feature + per-model tables. "At quota risk" counter on the overview surfaces creators >70% used.
  • Source tables: tts_call_log (migration 286) + llm_call_log (migration 279). Same rolling-30d window the TTS quota uses, so dashboard totals equal what 429 tts_quota_exceeded would report.

Podcast-discussion generator (Phase 1 — topic → script)

Type a topic → an LLM writes a natural two-person podcast conversation between a HOST (an AI co-host) and a GUEST (you), for eventual render with your own cloned voice + a host voice. Phase 1 (shipped): script only — voice-independent, no ElevenLabs spend.

  • Service: services/podcast-discussion.tsgenerateDiscussionScript(source, opts) (source is a topic or a content union; only topic is exposed for now) → claude-sonnet-4-6, response_format: 'json_object'. The pure, golden-tested parseDiscussionScript(raw, topic) (test, 8 cases) normalizes the LLM output → validated { topic, title, lines: [{ speaker: 'host'|'guest', text }] } (speaker-label coercion, alternating fallback, empty-line drop, length clamps, never throws).
  • Route: POST /api/podcast-discussion/script (creator-gated — spends LLM credits) { topic, exchanges? }{ script }.
  • Phase 2 (shipped): audio render. renderDiscussionAudio(script, opts) renders each line via ttsSegment (host = a VOICE_BANK voice, guest voice) → concatMp3BuffersuploadAudio (season-assets). POST /api/podcast-discussion/render (creator-gated; render bounded per creator by the TTS quota → 429; gated on ELEVENLABS_API_KEY → 503). Guest voice auto-wires to the caller's own Lovio clone — explicit guestVoiceIdgetActiveVoice(userId) from lovio_user_voices (record once at /dashboard/lovio/voice-setup) → PODCAST_GUEST_VOICE_ID env → stock stand-in (Antoni); host default Matilda / PODCAST_HOST_VOICE_ID.
  • Content sources (shipped): both routes take a source — topic, pasted content (+label, NotebookLM-style), a url (SSRF-guarded fetch + readable-text extraction — fetchReadableText/extractReadableText in link-preview.ts, golden-tested), journalEntryId (the caller's own entry — ownWriting, guest reflects on their own words), or seasonId (a story's premise/setting/genre). resolveSource in the route fetches + validates each (journal entries are user_id-scoped).
  • Persistence + library (shipped, migration 460): podcasts are first-class. podcast_discussions (owner-scoped, RLS-on/no-policies — backend service-role only) stores the parsed script (jsonb), audio_url, voices, style, exchanges, line_count, and the SOURCE it was made from — source_type (topic | content | url | journal_entry | story) + source_id (the journal_entries.id / story_seasons.id when applicable). /render now savePodcastDiscussion(...) after rendering (save failure logs but still returns the paid-for audio) and returns the new row id alongside { script, audio }. Service helpers: savePodcastDiscussion, listUserPodcasts, getPodcastsForEntry, getPodcast, deletePodcast, listHostVoices. Owner-scoped routes (NOT creator-gated — a user manages their own library): GET /api/podcast-discussion/mine, GET /for-entry/:entryId, GET /:id (404 if not theirs), DELETE /:id, and GET /voices (curated ~8-voice Host subset of VOICE_BANK). Route ordering puts /mine,/for-entry/:entryId,/voices before /:id so they aren't shadowed.
  • Host voice + style (shipped): both /script and /render accept hostVoiceId (from GET /voices) and styleDiscussionStyle = conversational | reflective | analytical | playful | debate (STYLE_DIRECTION appends a one-sentence tone fragment to the script system prompt; isDiscussionStyle() guards; default conversational).
  • UI (shipped): creator studio at /dashboard/podcast — source picker (topic / paste / URL / journal id / story id), host-voice picker + style picker, exchange count, "Generate script" (cheap) + "+ Render audio" (spends), transcript + inline player, and a "My Podcasts" library (fetch /mine; per-card title + source link + <audio preload="none"> + delete; refreshes after a render). Deep-linked via ?journalEntryId=<id> (pre-fills journal mode; page split so useSearchParams sits inside a <Suspense> boundary). The journal entry detail page (entries/[id]/page.tsx) shows an EntryPodcastPanel — any podcasts made from that entry (via /for-entry/:id) with inline players, plus a "🎙️ Make a podcast from this entry" deep-link. Discoverable via a "My Podcasts" entry in FeatureMenu.tsx (→ /dashboard/podcast#my-podcasts).
  • Public sharing (shipped, migration 461): podcast_discussions.is_public (default false). Owner toggles via PATCH /api/podcast-discussion/:id/public { is_public } (setPodcastPublic, owner-scoped) from a 🌐/🔒 control in My Podcasts (+ copy-link). Public read GET /api/podcast-discussion/public/:id (maybeAuthenticateUsergetPublicPodcast; only is_public rows; owner gets can_manage) — deliberately omits source_id/source_label so a public page never back-links into the owner's private entry. Public share page /discussion/[id] (server component, OG metadata, Next-15 params Promise) renders title + source descriptor + audio player + HOST/YOU transcript via DiscussionShareView, which also carries a listener share row (copy-link + 𝕏 / Bluesky / Facebook). A purpose-built 1200×630 OG card at /api/og/discussion/[id] (@vercel/og, edge; title + source + waveform motif) is wired into openGraph.images + twitter.card=summary_large_image so shared links unfurl with a visual. Public directory: GET /api/podcast-discussion/public (listPublicPodcasts — is_public + listenable, newest, no source linkage) feeds /discussions — a logged-out-viewable grid of public discussions linking to each /discussion/:id (cross-linked from the studio's My Podcasts header + the share page footer). Subscribable: GET /api/podcast-discussion/rss (listPublicPodcastsForFeed) is an iTunes-compatible RSS feed of the newest public discussions (audio enclosures, hivejournalOrigin() + escapeXml/formatRfc822, registered before /:id), surfaced as a "🎙 Subscribe (RSS)" link on /discussions.
  • Voice tiers (shipped): /dashboard/lovio/voice-setup offers Quick (one 60s reading) and Studio (upload several minutes of audio / multiple files) — both go through ElevenLabs instant voices/add, which accepts many files, so more clean audio yields a richer clone. createVoiceClone + the /api/lovio/voice/setup route take a samples[] array. Deferred (product_tasks): a true async Professional-Voice-Clone path (trained over hours) if the Studio clone isn't enough.

TTS quota warning emails (studio-notifications.ts:sendStudioQuotaWarningEmail)

Active channel paired with the passive spend chip + dashboards. First time a creator crosses 80% or 95% of their rolling-30d TTS cap in a 14-day window, fires a heads-up email (80% = "you're getting close" / 95% = "reply for a cap bump"). Closes the "user gets surprised by 429 mid-render" gap.

  • Schema: migration 302tts_quota_warnings table tracks (user_id, threshold, used_chars, limit_chars, email_status) so the throttle ("no email at this threshold in last 14 days") survives restarts and one Resend transient failure doesn't cause every render to re-send.
  • Wiring: services/tts-spend.ts maybeFireQuotaWarning(userId) runs fire-and-forget after every successful logTtsCall insert. Picks the highest crossed threshold so a 70→96% jump triggers the 95% email (not the 80% one); the throttle still independently tracks both thresholds.
  • Skipped for: super-admins (unlimited cap), users with limit=0 (no audiobook access — they're 403'd elsewhere). RESEND_API_KEY missing → email_status='skipped_no_key' logged so the count is visible without spam.

Studio pricing model — usage / credits, one-time trial (live 2026-06-12)

Superseded the $5/mo Graphene+ bundle. The launch model is usage/credits, no subscription: a new creator gets a one-time trial (≈20k chars), then buys one-time credit packs to render more — see the enforcement entry below. /studio/pricing leads with "start free → pay as you go" and the subscription tier is removed. (The credit-pack mechanics in the bullets below are unchanged; only the framing + the rolling-30d free allowance changed — paid-mode creators carry tts_monthly_char_limit = 0 and draw from the trial + purchased credits instead.)

  • Schema: migration 304studio_credit_packs audit table (one row per Stripe checkout or admin comp, unique idx on stripe_payment_intent_id for webhook idempotency) + profiles.studio_extra_credits_chars int column for the running balance (decremented on TTS usage that spills past the rolling-30d base quota).
  • Effective quota math: services/tts-spend.ts getMonthlyUsageForUser() returns limit = base_limit + extra_credits so both the preflight quota check and the /studio/billing UI see the combined cap. Super-admins (NULL base) ignore credits.
  • Stripe integration (live): services/studio-credit-packs.tsCREDIT_PACKS catalog (small=100k/$50, large=500k/$200 as of the #270 self-sustaining repricing to ~$0.50/1k; was 500k/$10 + 1.5M/$25 — when changing these, update the matching Stripe Price objects behind STRIPE_PRICE_STUDIO_CREDITS_SMALL + _LARGE), createCreditPackCheckout() for the Stripe one-time payment session, recordCreditPackPayment() for the webhook side (atomic audit-row insert + balance bump, idempotent via the unique-on-payment_intent index — duplicate webhook silently no-ops at 23505). POST /api/studio/credits/checkout exposes the checkout; the webhook handler at /api/payment/webhook branches on metadata.kind === 'studio_credit_pack'. UI: "Buy more credits" panel on /studio/billing (CreditPackPanel) + the same CTA inline on the warning email + the cap-request form copy.
  • Admin comp UI (/dashboard/admin/credit-packs): super-admin surface to grant credits without a Stripe checkout (refund a failed render, comp a beta tester, promo). Wraps the existing grantCreditPack() helper. Grant form takes email-or-UUID lookup, char-amount input with quick-preset chips (500k/1.5M/5M), required audit note (≥3 chars). Activity log mixes Stripe purchases (tagged PURCHASE, with $ amount) and comps (tagged COMP, with admin's note). Backend: GET /api/studio/admin/credit-packs (hydrated list) + POST /api/studio/admin/credit-packs/grant (calls grantCreditPack()). Belt-and-suspenders super-admin re-check at both endpoints — the file-shared requireSuperAdmin in studio.ts accepts is_creator OR super_admin, but credit grants are super-admin ONLY (otherwise creators could comp themselves).
  • Creator-side attribution (/studio/billing CreditPackPanel): when an admin comps a pack, the creator's balance bumps. Without attribution the creator just sees an unexplained number jump. The "Recent grants" list inside CreditPackPanel surfaces the last 10 grants with two variants: PURCHASE rows (emerald accent) show +chars · $amount · via Stripe Checkout so the creator can cross-reference their bank statement; COMP rows (purple accent) show +chars · <audit note> with the admin email prefix stripped — the creator-facing surface shows just the reason. Backend: getBillingForUser() + getBillingDetailForUser() both pull from studio_credit_packs in parallel with their existing reads (no extra round-trips). listBillingForAllCreators() (admin overview, every creator) returns empty grants to avoid N+1; drill-down has the per-creator history when needed.

Turns the previously copy-only NEXT_PUBLIC_STUDIO_PAID_MODE flag into real payment + legal enforcement. All three pieces are inert until the flag is true; closed-beta behavior is unchanged and existing beta creators (base_limit 20k/500k) are grandfathered on the rolling model. PRs #545 (enforcement), #546 (paywall UX), #547 (consent).

  • One-time trial + prepaid balance (tts-spend.ts, studio-credit-packs.ts): in paid mode, creator grants set tts_monthly_char_limit = 0 (no recurring free chars) + seed a one-time source='trial' credit-pack row (STUDIO_TRIAL_CHARS, default 20k, idempotent via maybeGrantStudioTrial). A "prepaid creator" (is_creator + base_limit 0) has balance computed authoritatively from the append-only ledgersΣ(studio_credit_packs.chars) − Σ(tts_call_log.character_count), lifetime on both sides — in the prepaid branch of getMonthlyUsageForUser, so a missed fire-and-forget credit decrement can't let usage drift past what was paid for, and a trial that ages out of the 30-day window never refills.
  • Render preflight (story-seasons.ts preflightStudioCreditsOrRespond): render-audio + render-all-chapters estimate the render's characters (prose of chapters to render) and return 402 { code: 'studio_credits_required', remaining, estimate, packs } before any spend. Scoped to real paying creators — skipped for super-admins, platform-writer fleet seasons, and legacy owner-less seasons so it can't halt Graphene's own auto-generated content. The per-segment quota gate in ttsSegment stays the mid-render backstop.
  • Paywall UX (CreditPaywallModal.tsx + extractCreditPaywall): apiRequest now attaches { status, code, body } to thrown errors (lib/api.ts) so callers branch on the server code. The modal reuses the existing /api/studio/credits/checkout Stripe flow; wired into ChapterRenderAudioButton, RenderAllChaptersModal, and SeasonClientPublishStrip.
  • Recorded terms-acceptance gate (migration 359, studio-terms.ts, StudioConsentGate.tsx): profiles.studio_terms_{accepted_at,version} capture versioned consent (bump STUDIO_TERMS_VERSION to force re-accept). GET /api/studio/consent + POST /api/studio/accept-terms; requireStudioConsent (middleware/studio-consent.ts, shared) gates /api/manuscript/parse + /create-season and the prompt/season-create route POST /api/story-seasons (paid mode, super-admins bypass, 403 studio_terms_required). StudioConsentGate wraps both /studio/new creation paths. Every "commit a creation" entry point is consent-gated.

Studio activation funnel (/dashboard/admin/studio-funnel, live 2026-06-12)

Measures became-creator → first render → finished M4B → published so an ad pilot is readable. Derived from durable Postgres (studio-activation-funnel.ts getStudioActivationFunnel, super-admin GET /api/studio/admin/activation-funnel?days=N) — not events, which get ad-blocked + can't be cohort-replayed. Cohort = creators granted in the window (profiles.creator_granted_at); stages 2–4 measured lifetime-forward: first render = tts_call_log with feature_key in chapter_render/season_render/audio_take; M4B = owned season with acx_export_rendered_at; published = owned season is_published_to_graphene. Migration 360 added profiles.creator_granted_at + story_seasons.published_to_graphene_at (stamped only-if-null in the grant paths + PATCH /:id/published; backfilled approximately for history). Ad attribution rides PostHog: server-side captureServerEvent fires studio_creator_granted / studio_acx_export_completed / studio_published_to_graphene (distinctId = creator UUID, inherits first-touch UTM/fbclid), since Postgres has no studio-intent marker on signup — the funnel's signup→creator ratio is all-signups (caveated in the UI), use PostHog UTM cohorts for per-campaign.

Paid-mode flag — closed-beta vs paid Phase 2 copy (lib/studio-mode.ts)

NEXT_PUBLIC_STUDIO_PAID_MODE is a build-time public env var that flips every closed-beta-framed user surface to paid Phase 2 framing in one config change. Flipping railway variables set NEXT_PUBLIC_STUDIO_PAID_MODE=true && redeploy swaps:

  • StudioChrome header CTA: "Join the beta" → "Subscribe" (→ /graphene)
  • /studio hero closed-beta funnel + CTAs
  • /studio/pricing "Active now" badge moves from Closed beta tier → Graphene+ tier, with CTA redirecting to /graphene subscription
  • /studio/beta (page metadata + landing): reframed as "Concierge onboarding" with subscribe-first messaging
  • /studio/dashboard onboarding tour: "Free during closed beta" → "Included with your Graphene+ subscription"
  • /studio/welcome eyebrow + "what's not enabled yet" billing note
  • /studio/help hero + ContactForm copy
  • Grant email body (sendStudioGrantEmail in studio-notifications.ts) — "closed beta is free, we're eating TTS cost" → "your access includes 20k chars/mo (~3–4 chapters), top up via /studio/billing" when paid (lowered from 500k in the #270 pricing rework)
  • Launch-readiness verdict banner (/dashboard/admin/studio-launch) — all three verdicts (GO / CAUTION / BLOCKED) reframe from "ready to flip" → "live deployment health" when the flag is on
  • Legal docs: /studio/terms is refreshed for the paid launch (effective 2026-06-12) and acceptance is now recorded + versioned via the consent gate (see "Studio paid-mode enforcement" above) — not flag-gated, but no longer Phase-1-only. Still flagged as not-yet-attorney-reviewed; a legal pass is recommended before scaling ad spend. (/studio/pricing + the header/CTA copy that previously pointed at a /graphene subscription now route into the trial + credit-pack flow.)
  • Why a public env var instead of a backend feature-flag table: the closed-beta → paid switch is a deliberate, one-time GTM event — not a per-user or A/B experiment. Centralizing the decision on one env var means the copy across /studio + /studio/pricing + /studio/dashboard + the beta-form success state all flip together.
  • Backend mirror (apps/backend/src/services/studio-mode.ts) — one-line studioPaidMode() helper reading the same NEXT_PUBLIC_STUDIO_PAID_MODE env var. Backend can read NEXT_PUBLIC_* env vars as regular process.env reads; the prefix is a Next.js convention for client-bundle inlining, not a Node restriction. Mirrored rather than imported across the apps/ boundary because the two apps don't share a published package.

Self-serve TTS cap-increase requests (/studio/billing)

Companion to the warning emails — gives the creator a UI to request a higher cap instead of "reply to your welcome email." Inline form on /studio/billing under the TTS-spend card.

  • Schema: migration 303studio_cap_requests table tracks (user_id, used_chars, limit_chars, pct_used, message, status, email_status, resolved_at, new_limit_chars). Status lifecycle: openapproved / declined / superseded.
  • Throttle: at most one open request per user per 24h (route returns 429 with code: 'cap_request_pending' so the UI can show "you already have a pending request").
  • Backend: POST /api/studio/cap-increase-request in routes/studio.ts — fires sendStudioCapIncreaseRequestEmail (admin-bound, reply-to=creator) with the usage snapshot + optional rationale + deep link to /dashboard/admin/studio-billing/<userId>. Inserts the row regardless of email outcome so the throttle survives a Resend blip.
  • Admin resolves at /dashboard/admin/cap-requests — open queue at top, last 25 resolved for context. Approve bumps tts_monthly_char_limit + flips status in two writes (cap first; if profile update fails, the request stays open so the admin can retry rather than losing the queue entry). Decline closes without bumping; creator can re-request after the 24h throttle. Backend: POST /api/studio/admin/cap-requests/:id/approve + /decline (super-admin only; belt-and-suspenders re-check inside each handler since the shared requireSuperAdmin in studio.ts accepts is_creator OR super_admin).

Per-creator usage view (/dashboard/admin/creator-usage)

Lightweight observability v1: derives spend from existing cost_cents columns on take + critique tables, attributed via generated_by_user_id. Doesn't introduce a usage_events table — that's a future upgrade for paid Studio when we need real-time caps.

  • Service: services/creator-usage.ts — sums take cost + critique cost per user, tracks 28-day rolling spend, hydrates with profile + email metadata via auth.admin.getUserById.
  • Route: GET /api/admin/creator-usage (super-admin).
  • UI: top-line stats (total cost / 28d cost / takes / critique runs) + per-creator rows with role badges + take/critique cost breakdown + first/last activity. Linked from /dashboard/admin as 📈 Creator usage.
  • v1 known caveats (called out on the page): audio render (ElevenLabs), image gen (DALL-E), music gen (Replicate) aren't attributed yet — counted as platform overhead. When paid Studio tiers ship, upgrade to a proper usage_events table that records every LLM/TTS/image call with the requesting user.

Bulk "Improve all behind" + season-level version status

Closes the loop on platform versioning so version bumps don't require N clicks per season.

  • Service improveAllBehindChapters: sequential loop. Queries story_episode_takes WHERE status='live' for the season, filters to those whose platform_version is behind current via isVersionBehind, then iterates calling autoRefineNextChapter({ existingChapter }) per behind chapter. Failure-isolated — one chapter erroring doesn't halt the rest. Returns { total, improved, failed, per_chapter[] }.
  • Route: POST /api/story-seasons/:id/improve-all-behind (super-admin) — fire-and-forget, tracked under the full_pipeline stage; runs feed surfaces per-chapter progress.
  • Per-row badge on /dashboard/admin/seasons: every show row now gets a platform-version chip — on v1.2 (cyan) when all chapters are current, 5/12 behind v1.2 (fuchsia) when there's drift. Driven by a new platform_version_status field added to the GET /api/story-seasons response (one batched query joins live takes; no N+1).
  • Aggregate banner: when one or more shows have behind chapters, the seasons admin page surfaces a top-of-list banner — 🚀 3 shows have chapters behind v1.2 · 8 chapters total · est. cost $0.40–$0.80. Click into a show to bulk-improve; the trigger is intentionally inside the chapter modal (not the banner) so admins see which chapters specifically before paying.
  • Bulk button in EpisodesAdminModal header: when behind_count > 0, shows 🚀 Improve ALL N behind v1.2. Confirms with chapter count + time + cost estimate before firing. After click: runs feed shows each chapter's auto-refine in turn; live takes update one-by-one; old takes archived not lost (rollback via takes modal).
  • Spine + stakes pre-pass prompt: after the v1.9 plan-anchored shape upgrade, the per-chapter chapter_spine + chapter_stakes planning data needs to be recomputed before the bulk improve runs, otherwise each chapter's prompt embeds stale spine/stakes blocks and the role/target-anchored escalation never lands. The "Improve all behind" flow now asks a second confirm — "Also regenerate chapter spine + stakes FIRST?" — and on yes calls POST /regenerate-chapter-spine then POST /regenerate-chapter-stakes synchronously before kicking off the bulk improve. Each pre-pass step is wrapped in its own try/catch so a failure surfaces in the message but doesn't block the bulk improve itself. Adds ~10–20s + ~$0.01 to the run.
  • Cross-show "Improve EVERY chapter" pre-pass: the same prompt is wired into the bulk button on /dashboard/admin/seasons (page.tsx bulkImproveAllBehind). On yes, fans out spine + stakes regen calls across every show-with-behind-chapters in parallel via Promise.allSettled (one show's pre-pass = ~10–20s, so the parallel wall-time stays roughly the same regardless of show count); failures are tallied into the status message but don't block the bulk improves. Cost scales linearly: ~$0.01 per show.
  • Public season-page banner pre-pass: ImproveAllBehindBanner (admin-only banner rendered on /seasons/[id]) gets the same follow-up confirm as the admin modal. On yes, hits regenerate-chapter-spine then regenerate-chapter-stakes synchronously (each in its own try/catch — failure surfaces in the message but doesn't block the improve), then fires improve-all-behind as before.

Platform Version system + per-chapter "Improve" flow

Migration 136 stamps every chapter take + season script take with the platform version that produced it (platform_version text column on story_episode_takes + season_script_takes; legacy rows backfilled to 'legacy'). Versions are an append-only registry in services/platform-versions.ts — each entry captures the default LLM model, default critic keys, and a 1–3 sentence note on what changed. Bump the version when a meaningful improvement ships (new critic, new prompt strategy, model upgrade); skip cosmetic changes.

Why on top of the existing model_key column: model_key tells you which LLM produced the prose. Platform version captures the critic set + prompt strategy + any pipeline changes that ship together. Two takes on the same model can land at different qualities depending on which critics ran.

  • Stamping: generateChapterTake in episode-takes.ts and the season-level generateTake in season-takes.ts lazy-import getCurrentVersion() and write the result on every insert. New takes always carry the active version.
  • Surface in API: GET /api/story-seasons/:id enriches each episode with live_take_platform_version + live_take_model_key (super-admin only). New endpoint GET /api/story-seasons/:id/version-status returns the full registry + per-chapter status + behind_count + total_chapters for batch tooling.
  • "Improve" pipeline: POST /api/story-seasons/:id/episodes/:n/improve (super-admin) regenerates a single already-triggered chapter through the current platform — full auto-refine flow (take 1 → critics → apply findings → take 2 → promote → render audio). Routes through the existing autoRefineNextChapter with a new existingChapter opt that skips the trigger-or-extend step. ~60-90s wall time, ~$0.05–0.10 per chapter (LLM critique runs + audio re-render); previous live take is archived (not lost) so admins can roll back via the takes modal if a regen feels worse.
  • UI: per-chapter Platform v{X} badge in EpisodesAdminModal.tsx (cyan when current, fuchsia when behind, with hover tooltip explaining what'd change on improve). When the live take is behind current, a 🚀 Improve to v{current} button appears inline with the other chapter actions. Frontend mirrors the version constant via lib/platform-versions.ts (CURRENT_PLATFORM_VERSION — keep in sync with the last entry in the backend registry).
  • Multi-brand domain aliasing (middleware.ts, setup guide): one Next app, three branded front doors. hivejournal.com is the canonical umbrella; write.cafe and graphene.fm are brand domains that REWRITE (not redirect) into the corresponding sibling-surface routes — visitors stay on the brand they arrived through, even as they navigate within the surface. Host-aware middleware strips www., then on write.cafe: //write-cafe, /sprints/write-cafe/sprints. On graphene.fm: //graphene. Out-of-surface paths pass through (a writer who logs in via write.cafe can keep using the platform without bouncing). DNS = CNAME each apex + www at Vercel; add domains in Project Settings; TLS auto-provisions. Setup guide covers DNS, Vercel config, local-test patterns, and how to add a fourth brand.
  • AI personas at the cafe — AI personas (ai_personas table) appear as patrons in the writers'-room widget. New getActivePersonas() service + GET /api/write-cafe/active-personas endpoint returns personas who've published a journal_entry in the past 24 hours, with their word totals + last-active time. Front-end renders persona cards with a small "AI" badge in purple-ish tone (vs human cards' amber) and the cards are CLICKABLE — they link to /actors/[user_id] (the public actor profile, where users can browse the persona's journal entries + bio + traits). Card-render order: real human cohort writers → personas → faux placeholders, capped at 8 cards. The room therefore feels populated even at low traffic, and a curious visitor can click any persona to dig into their journal stream.
  • write.cafe ↔ HiveJournal entry integration — what writers actually do in HiveJournal feeds the cafe directly. New getJournalWordCount(userId, fromIso, toIso) service + GET /api/write-cafe/journal-word-count endpoint sums words across journal_entries.content in a half-open ISO window (frontend ships the local-day boundaries; server stays timezone-naive). Skips JSON-shell entries (graphic_novel/outline payloads). Surfaces in two places: (1) the cafe page shows a "📓 You've written X words in HiveJournal today" banner inside the check-in card with a one-click "Use this" pre-fill button, and (2) a WriteCafePill on /dashboard/entries/new shows a member's running today total + draft-in-flight + streak with a link to /write-cafe; non-members get a "Join write.cafe" CTA. Pill is dismissable for 14 days via localStorage so it doesn't nag. Plus a "writers' room" widget on the cafe page — 6 ambient cards with simulated typing pulses + heavily blurred lorem-ipsum-style placeholder text — designed to evoke a coffee shop without ever showing anyone's actual writing (privacy + a non-empty room when the cohort is small). Real cohort members fill the first slots; faux presences ("a writer at the corner table") fill the rest.
  • write.cafe — sibling surface for the writers' room (migration 162): daily word-count ritual + 30-day sprints + "active this week" cohort. Sibling to Graphene under the HiveJournal umbrella; same auth + DB + Stripe. The owner's write.cafe domain DNS-proxies / 301s to /write-cafe. Tables: write_cafe_word_counts (one row per user-day, upsert-keyed for streak math), write_cafe_sprints (any-month 30-day pushes), plus profile flags (write_cafe_member + write_cafe_display_pref). Service: joinCafe/leaveCafe, logWordCount, getMyCafe (streak math + 30-day totals), getActiveWriters (last 7 days, display-pref-respecting), sprint CRUD + progress. Routes at /api/write-cafe. UI: /write-cafe (hero + join card + daily check-in + cohort strip + sprints summary) and /write-cafe/sprints (create + manage). Slim-nav swaps to WriteCafeHeaderBrand (coffee/amber palette vs Graphene's purple) with sibling-surface jump to Graphene. Deliberately doesn't depend on NaNoWriMo's API or brand — the daily-ritual behavior matters more than ever in an AI era because logging "I wrote X words" is the human signal that distinguishes writer work from prompted output.
  • Creator subscription pool — pro-rata pro-rata splits (migration 161): monthly cadence Spotify-model pool. creator_payout_periods table holds period dates + pool_cents (admin-entered or future Stripe-derived) + status lifecycle (draftcomputedpayingpaid); creator_earnings.period_id back-points so admin can audit a row to its source period. Service: creator-subscription-pool.tscreatePeriod (declare a date range), computePeriod (reads chapter_listen_sessions filtered to subscriber-only minutes on creator-owned shows, attributes minutes per creator, computes per-creator allocations using their subscription_creator_pct from creator_revenue_splits, writes pending earnings rows), payPeriod (fires stripe.transfers.create for each pending row to the creator's Connect account; rows where the account isn't currently active flip to platform_retained for later reconciliation), archivePeriod (escape hatch — pending rows roll back to platform_retained). Routes mounted at /api/admin/creator/payouts: GET /periods, POST /periods, POST /periods/:id/compute, POST /periods/:id/pay, POST /periods/:id/archive, GET /periods/:id/earnings, POST /reconcile/:userId (admin variant of the creator-self reconcile). UI: /dashboard/admin/growth/payout-periods — period list with create form, per-period Compute/Pay/Archive buttons, and per-period allocation drilldown showing each creator's gross + share + status.
  • Creator reconciliationreconcileRetainedEarnings() batch-transfers every platform_retained earning for a creator to their now-active Connect account (idempotent; returns per-row results). Two routes: POST /api/creator/payouts/reconcile (creator-self, the common case after onboarding) + POST /api/admin/creator/payouts/reconcile/:userId (admin variant). UI: a "💰 $X held from before you connected Stripe" CTA banner appears on /dashboard/creator/earnings when account active + retained share > 0; one click runs the batch.
  • "For Ben" creator-monetization pitch page (/dashboard/admin/for-ben) — narrative explainer of the writer experience end-to-end: write.cafe as the daily-ritual home (where the habit lives), then the three Graphene revenue streams (tips 80/20, subscription pool 70/30, Drift 90/10) with concrete split numbers + demo tables, then the unified earnings ledger, then a competitive-positioning grid (vs. Substack, Spotify, NaNoWriMo+Discord+Substack stacked, traditional publishing, build-it-yourself). Linked from the Graphene admin submenu. Every claim cross-references the live mechanism in this doc; useful for on-boarding a writer or making the case to a stakeholder. Also surfaces a small write.cafe banner on /dashboard/admin/growth framing the cafe as the "upstream funnel" for monetization (no money flows through the cafe directly; it's where the ritual that produces the work lives).
  • Creator Studio payouts — Stripe Connect Express + tip revenue share (migration 160): three new tables. creator_payout_accounts (one per creator: stripe_connect_account_id + cached charges_enabled/payouts_enabled/details_submitted/requirements), creator_revenue_splits (per-creator override of the platform-default percentages — defaults: tip 80%, subscription 70%, drift 90%), and creator_earnings (the append-only ledger: gross_cents / creator_share_cents / platform_share_cents / stripe_charge_id / stripe_transfer_id / statuspending / transferred / reversed / platform_retained). Service: creator-payouts.tsensureConnectAccount (idempotent Connect Express creation), createOnboardingLink (one-time AccountLink for KYC), createDashboardLink (Express dashboard for creators to manage payouts), syncAccountStatus (refreshes from Stripe on every status check), computeTipSplit (resolves season owner → splits at percentage → returns connect account id when transferable), recordEarning + markEarningTransferred (ledger lifecycle). Tip flow (episode-tips.ts) updated: when the season's owner has an active Connect account, the Stripe Checkout session sets payment_intent_data.transfer_data.destination + application_fee_amount so funds split at the moment of charge — no separate transfer call. Pre-Connect tips land as platform_retained rows in the ledger so reconciliation can find them later. Routes mounted at /api/creator/payouts: GET /status, POST /onboard-link, POST /dashboard-link, GET /earnings, POST /ensure. UI: /dashboard/creator/earnings — three states (no account / onboarding incomplete / active) + summary cards (lifetime, transferred, pending, retained) + earnings ledger table with per-row status badges. Linked from the Graphene admin submenu as "Earnings (Creator)" + cross-referenced from /dashboard/admin/growth.
  • SJ Anderson curated short-story collection (migration 186 + migration 188 + short-stories.ts service + short-stories-audio.ts service + /sj-anderson + /sj-anderson/[id]): a public author-profile page presenting "The Last Five" — five short stories curated from a tiny LLM-judged tournament. Lifecycle per story: pick one unused cafe_random_prompts row → 3 random active personas each write a candidate (gpt-4o, ~600–1200 words, in their voice via personalitySummary + a literary-magazine submission framing) → gpt-4o-mini judge picks the winner with reasoning (rejects AI-tells like "the air was thick with X") → winner persists at the next position ↑ on short_stories → ElevenLabs renders single-narrator MP3 inline (chunked at sentence boundaries, ffmpeg-concatenated, uploaded to season-assets/short-stories/<id>.mp3). Cost: ~$0.65 writing/judging + ~$3.60 narration = ~$4 per 5-story backfill. Auto-rotation: short_stories_backfill_tick cron (6h cadence, fires at most weekly) gates on (a) ≥5 unused ready prompts in pool AND (b) latest published ≥7 days old; heartbeat metadata records the gate decision. Manual override via "🎲 Run backfill now" button on the cafe-home admin ops panel + POST /api/short-stories/admin/backfill (super-admin). Public surfaces: /sj-anderson lists the latest 5 by position desc with album cover (iTunes lookup with DALL-E song-cell fallback) + per-ingredient color chips + first-paragraph teaser + "▶ Listen" pill (lazy native audio). Each story has its own permanent URL at /sj-anderson/[id] with full hero + serif body at 16-17px/1.75-leading + "About this selection" pull-quote + Web Share API button. Per-story OG card at /api/og/sj-anderson-story/[id] (1200×630 two-column: bento album art + typographic block) so Twitter/Slack shares unfurl with the right story. Collection-wide OG at /api/og/sj-anderson (S·J·A monogram). Author display name SJ Anderson is captured per row so future guest-curated weeks don't require schema changes.
  • Cafe random-prompt pre-generated pool with images (migration 185 + cafe-random-prompts.ts service + cafe-random-prompts.ts route + /write-cafe/random-prompt): the public random-prompt generator at /write-cafe/random-prompt was rolling fresh text-only bentos every visit, slow-loading album art on top. Now there's a server-side pool of 10 pre-generated prompts with DALL-E images for every ingredient (song / character / setting / motif / object / line). The page paints fully illustrated on first arrival; signed-in viewers won't see a prompt they've already done until they exhaust the pool (per-user dedupe via cafe_random_prompt_views); anonymous viewers get a random ready row without dedupe. Pool topped up by cafe_random_prompt_pool_tick cron (15m, generates up to 3/tick to cap cost) when ready < 10. Routes: GET /next (auth-aware, marks views), GET /featured (deterministic per-UTC-day pick, never marks views — powers the "Today's prompt" preview card on /write-cafe cafe home), GET /admin/stats + POST /admin/seed for super-admin manual control. Cost: ~$0.20 per prompt (5 DALL-E images), $2 initial seed, steady-state bounded by churn. The same pool feeds the /sj-anderson short-story backfills via the used_for_short_story_at stamp on each row (added in migration 186) so a prompt is never used twice across surfaces.
  • Super-admin cafe ops panel + carousel A/B test instrumentation (CafeOpsPanel.tsx + migration 187 + BookCarousel.tsx + cafe-admin.ts route): two new admin-only surfaces on the cafe home, both gated by useIsSuperAdmin() (cached in localStorage, 10-min TTL) so non-admins see the carousel + page exactly as before. (1) CafeOpsPanel — live snapshot of the tournament + rating pipeline at a glance: random-prompt pool with stacked 4-state lifecycle bar (ready / generating / pending / failed), short-stories collection state with backfill-readiness indicator + a "🎲 Run backfill now" button that fires the mini-tournament when pool ≥ 5 unused, tournament list (recruiting / live with current-round dot progress / recently completed in last 24h), battle pipeline 5-pill row (queued / paired / active / judging / closed-24h). One round-trip via GET /api/cafe/admin/ops-stats; refreshes every 60s. (2) The Last Five book A/B test — migration 187 adds three columns to cafe_books (internal_link, cover_image_url, click_count) so any internal page can be dropped onto the carousel disguised as an Amazon e-book. The SJ Anderson collection shows up there as "The Last Five" with a custom 600×900 OG-rendered cover (/api/og/sj-anderson-book-cover — dark amber→rose paperback, big serif title, "BY SJ ANDERSON" tracked caps); click goes to /sj-anderson instead of an Amazon URL. Click counts are bumped via fire-and-forget POST /api/cafe/books/:id/click before navigation; super-admins see per-card click chips (emerald 12 ↗ for internal, quieter amber count for Amazon books) + a shelf-wide engagement banner with totals + internal/Amazon split + top-3 performers. seedCafeBooks idempotently inserts the SJ Anderson row with its INTERNAL-SJ-LAST-FIVE sentinel asin so collisions with real ASINs are impossible.
  • Cafe Battle pending badge (ecosystem-wide)CafeBattlePendingBadge.tsx, mounted globally in ClientOnlyComponents so it hangs off the top of every page on every domain (hivejournal / graphene.fm / write.cafe — one Next build). For a signed-in user sitting in the battle queue it shows an unmissable top-center countdown to the AI-fallback time (ai_fallback_at) + time-in-queue, or a green "your battle is ready — write now →" once paired_battle_id is set; links into /write-cafe/battles[/:id]. Polls GET /api/cafe/battles/queue/status (getQueueStatus) every 25s with a 1s local countdown tick; renders nothing when signed out or not queued. Portal'd to document.body, mobile-safe (max-w-[calc(100vw-1rem)] + responsive text-xs sm:text-sm/truncation so it can't overhang the header) with a session-only ✕ dismiss keyed to the specific paired_battle_id (a new pairing re-surfaces it). Self-heal — the badge clears once the writing phase is over for the user, not just on close: getQueueStatus deletes the stale queue row (→ in_queue: false) when the paired battle is gone or has moved to judging/closed/cancelled, OR when the battle is still paired/active but this user has already finalized their entry (cafe_battle_submissions.submitted_at set). Fixed the bug where the green "write now →" nag persisted after submitting while waiting on the opponent/AI side.
  • Bluesky Author of the Day rotation (2026-05-30, migration 332) — daily spotlight on one of the curated cafe_bluesky_writers who has a recently published book. Each active writer holds a 30-day shelf slot (slot_start_at / slot_ends_at; is_active auto-flips false at slot end via a cheap idempotent sweep on every listWritersForStrip() read; pinned is immune). Each day a writer is featured: the picker prefers never-featured writers, ties broken by slot_ends_at ASC so each writer gets their feature before rotating off. The shelf shows a ✨ Today badge on the featured writer (amber-bordered card on both the BlueskyAuthorsStrip and the BookCarousel WriterCard, which now also leads with the book title). Backend: cafe-bluesky-author-of-day.ts (getTodaysAuthor / pickTodaysAuthor / generateAuthorOfDayDraft / featureWriterAndPost / autoFeatureToday). LLM voice: lowercase-leaning, tags @handle near the front so the author gets notified + likely reshares, names ONE specific thing (book title, an interview-answer phrase quoted in their own voice, an admin-written feature_lead), never says "AI", ≤280 chars, link included once (book URL when present, else bsky profile). New endpoints under /api/cafe/bluesky-writers: GET /today (public, returns today's featured writer or null), POST /today/auto (super-admin, auto-pick + post), POST /:id/draft-feature (super-admin), POST /:id/feature (super-admin, generate + post + stamp last_featured_at). Admin UI on the existing BlueskyAuthorsStrip: header gets a ✨ Auto-feature today button when no one has been featured today; per-card button triggers a single-writer feature; the add-handle form now captures book_title / book_url / book_blurb / book_published_at. Interview workflow wired into the schema in 332 (interview_status / interview_qa / interview_asked_at / interview_received_at / feature_lead) so a later PR can add the outreach UI without another migration — when interview answers are on file, the LLM quotes the author's own voice instead of riffing on bio/blurb.
  • Named Bluesky events for cafe activities (2026-05-30, migration 331) — PR A of the "auto-distribute cafe activity to Bluesky" trio. Ties a Bluesky kickoff post → a write.cafe activity → a result post threaded as a reply to the kickoff, so the whole arc reads as one Bluesky thread. The named event is the thing — e.g. "Saturday Afternoon 20-Minute Showdown" is one cafe_bluesky_events row, with a snapshotted bento (so re-rolls don't change rules mid-flight), a cached LLM-drafted kickoff text, the eventual bsky_kickoff_uri + bsky_kickoff_cid (CID is required for threading), and after the activity closes a cached result draft + a bsky_result_uri posted as reply.root+reply.parent of the kickoff. Four kinds (showdown / prompt / bento / open_battle) with distinct LLM voices; all four lead with the bento or the constraint (not "join me on write.cafe"), lowercase-leaning, never says "AI" (same doctrine as cafe-bluesky-drafts.ts). Status lifecycle: scheduled → kickoff_drafted → kickoff_posted → closed (with cancelled as the escape hatch). Backend: cafe-bluesky-events.ts (createEvent / generateKickoffDraft / fireKickoff / generateResultDraft / fireResult); route file at /api/cafe/bluesky-events (super-admin gated). bskyPost() in cafe-bluesky.ts extended to return cid and accept a reply: { root, parent } target so threaded replies work end-to-end. Admin manager at /dashboard/admin/cafe/bluesky-events — create event with optional bento roll, generate draft, fire kickoff, later generate + fire result. v1 manual fire only — no cron / recurrence yet so we can validate the format on a real Saturday before automating; cron + per-page "📣 Announce" affordances are deliberate follow-up PRs.
  • Bluesky writers in the BookCarousel (2026-05-30) — Sandon's curated Bluesky writers also show up inside the BookCarousel shelf alongside the books, so the carousel reads as "things to read on Amazon + writers to follow on Bluesky" rather than book-only. WriterCard mirrors BookCard's dimensions (150–170px wide, 2:3 aspect) so the shelf keeps one visual rhythm; avatar fills the "cover" slot, display name + handle live where title + author would, latest-post snippet sits in the blurb slot, and a distinct sky-toned border + 🦋 bsky badge keeps writers from camouflaging as another book. Writer cards lead the shelf when on the "All" chip; they don't appear when a book-category filter is active (writers have no category) or on the per-category /write-cafe/books page (which renders one carousel per book category via category prop, so the carousel sets includeWriters=false). Capped at 3 so the carousel is a teaser and the dedicated BlueskyAuthorsStrip (full list + admin curation) stays the "see all" surface — avoids rendering the same writers twice on the /write-cafe homepage. Same data source as #2 (GET /api/cafe/bluesky-writers); no new backend/migration.
  • Bluesky writers strip on /write-cafe (2026-05-30, migration 330) — curated horizontal strip of writers to follow on Bluesky, rendered between BookCarousel + FromTheFloor on the write.cafe homepage. Each card: avatar + display name + a one-line snippet of the writer's latest post + Follow-on-Bluesky link. Backend service cafe-bluesky-writers.ts pulls app.bsky.actor.getProfile + app.bsky.feed.getAuthorFeed?limit=5&filter=posts_no_replies from the public AppView (https://public.api.bsky.app/xrpc/...) — no auth, no rate-limit theatrics. Profile + latest-post fields are cached on cafe_bluesky_writers (migration 330) with a 1h TTL; listWritersForStrip() lazily refreshes any stale rows in parallel before returning, bounded by a 2.5s soft deadline so a slow bsky API can't stall the homepage (stale values render in the meantime). Routes (cafe-bluesky-writers.ts): GET /api/cafe/bluesky-writers (public), POST / + PATCH /:id + POST /:id/refresh + DELETE /:id (super-admin). UI (BlueskyAuthorsStrip.tsx) hides empty strip from non-admins; super-admins get an inline + Add handle row + per-card (force refresh) and (remove) so curation lives next to the strip rather than in a separate admin page. Handles are normalized (bare names → .bsky.social); first refresh fires immediately on add so the new card doesn't render blank.
  • Cafe-battle Bluesky invite + result drafts (2026-05-30, migration 329) — closes the "I want to invite someone from Bluesky to write with me" friction. Two LLM-drafted post flavors via cafe-bluesky-drafts.ts (claude-sonnet-4-6, ~$0.01-0.02 per draft): (1) Invite draftsPOST /api/cafe/battles/invite-draft body { bento?, mentions? }. Auth'd users only. If no bento provided, rolls a fresh one via generateRandomBento() and returns it alongside the draft so the panel can show the prompt the invitee is being challenged to. Mentions are normalized (bare names get .bsky.social appended), capped at 3 to avoid spam-tone posts. The prompt explicitly leads with the BENTO or MATCHUP, not with "join me at write.cafe" — the constraint is the hook. Lowercase-leaning, max 1-2 emojis, no "AI" mention (write.cafe is a craft community, same doctrine as the Graphene community-post draft). Not cached — each click re-rolls the post for freshness. UI: collapsible BlueskyInvitePanel on /write-cafe/battles under the QueueCard — mentions input + bento preview + Draft/Re-draft/🦲 Roll-new-bento + Copy + Open-Bluesky-compose. Posting stays manual (paste into bsky.app from the user's own account, which keeps the post in their voice + their network). (2) Result draftsPOST /api/cafe/battles/:id/result-draft (auth'd) generates + caches a Bluesky post for a closed battle on cafe_battles.bluesky_result_draft (JSONB column, migration 329); GET /api/cafe/battles/:id/result-draft (public, no auth) returns the cached draft so re-opening the closed-battle page costs $0 across all viewers. UI: BattleShareToBluesky component fetches the cached draft on mount, shows "✨ Draft Bluesky post" when absent, switches to the post text + Copy + Open-Bluesky + Re-draft once generated. Lives inside ClosedRecap on /write-cafe/battles/[id] below the critic transcripts. Voice rules: name BOTH writers (not just the winner), no victory-lap energy, 1 ingredient + the song + the URL, ≤280 chars. Battle URLs are already public for closed battles (existing /api/cafe/battles/:id/public + OG metadata at /api/cafe/battles/:id/meta), so the shared Bluesky post unfurls cleanly without needing any new sharability infrastructure.
  • Cafe-battle watch-page UX pass — empty-state catalog + presence + status pills + auto-follow (/write-cafe/battles/watch + GET /api/cafe/battles/recent-closed + listRecentClosedBattlesPublic): five enhancements on the spectator gallery. (1) Live-aware H1 — when a battle is paired/active, swap the static "Watching from the corner" headline for "Live now: {A name} vs {B name}" + a countdown to the next deadline. Falls back to the brand line during empty/judging. (2) Concurrent-watcher count — every open watch tab joins a Supabase Realtime presence channel (cafe-watch-room) with a random per-tab key; presence sync delivers an Object.keys(state).length count rendered as a "👀 N watching" pill in the header. Multi-tab from the same user counts multiple times by design (this is "concurrent watch sessions," not "unique users"). (3) Empty-state catalog — when no battle is in flight, replace the dead "AI personas should fire one up soon" card with a 6-card grid of recent closed battles (song, both writers' names, score, winner highlight, stake/ranked tags, relative timestamp), each linking to the full transcript. Backed by new GET /api/cafe/battles/recent-closed?limit=N route → listRecentClosedBattlesPublic() service. The same component also surfaces below an in-flight battle as a compact "Recent matches" footer, giving spectators a path to past transcripts without leaving. (4) Per-pane status pill — explicit color-coded <PaneStatusPill> replaces the previous opacity + tiny dot + checkmark mix: gray "yet to start", emerald-pulsing "writing · 14:32 · 247w" (rose-pulsing under 60s), emerald "✓ submitted · 312w", purple "📜 judging", winner/tie/closed pills post-close. State derived once via panelStateFor() so the rules live in one place. (5) Auto-follow-bottom on writing panes — each WatchPane gets a ref + onScroll handler; if the spectator is at the bottom (within 24px), new text auto-scrolls to keep up; if they scroll up to read earlier prose, the page honors that and stops following, surfacing a "↓ follow live" button to re-stick. Reads like a chat log during live writing. Pre-existing Realtime sub on cafe_battle_submissions UPDATEs unchanged.
  • Cafe-battle tournaments — single-elimination brackets (migration 182 + cafe-tournaments.ts service + cafe-tournaments.ts route + /write-cafe/battles/tournaments + /write-cafe/battles/tournaments/[id]): bracket-style tournaments riding on top of the existing battle loop. Bracket sizes 4 / 8 / 16, fixed at create. Lifecycle: recruiting → live → completed (or cancelled). Writers join during recruiting; entry stake (in Drift coins) escrows into a per-tournament total_pot_coins. Admin starts the tournament — service shuffles entries, assigns seeds, refunds dropped writers if undersubscribed (rounded down to nearest power of 2 ≥ 4), and creates round-1 battles in seed-order pairs (1v2, 3v4, ...). All matches in a round share a single bento snapshot so cross-matchup comparison is meaningful. The standard battle lifecycle (paired → active → judging → closed) carries each match; the existing 24h hard expiry + no-show forfeiture rules apply per-match. Bots never play in tournaments — every match is human-vs-human, no-show walkover advances the present writer. Per-match coin_stake_per_side is 0 (the only money is the tournament pot). is_ranked propagates from tournament → battle so ELO updates flow through the existing close-time path. Auto-advance via the new runTournamentTick() (rides the existing 2-min cafe-battle cron): scans live tournaments, when ALL battles in current_round are closed/cancelled it collects winners (random advance on tie), stamps eliminated_at_round on losers, creates round N+1 battles. When 1 winner remains → completeTournament pays out the pot per prize_split_pcts (default [100] winner-takes-all, configurable to e.g. [70, 20, 10]). Public list at /write-cafe/battles/tournaments; bracket detail at /write-cafe/battles/tournaments/[id] shows entries with seeds + every round's matches with phase + score + winner highlighting. Admin create/start/advance/cancel routes under /api/cafe/tournaments/admin/*.
  • Cafe-battle coin stakes + opt-in ELO (migration 181): adds coin_stake_per_side + is_ranked + elo_change_* to cafe_battles; adds coin_stake + wants_ranked to cafe_battle_queue; adds cafe_battle_elo (default 1200) + cafe_battle_ranked_battles/wins/losses + cafe_battle_ranked_optout to profiles. Pairing matches on exact (coin_stake, wants_ranked) so 0/25/50/100/250 each form a queue band — a 25-coin queuer never gets pulled into a free battle. Stakes escrowed at pair time via the existing mutateBalance helper, with rollback if either side's wallet dropped between queue and pair. On close: tie → refund both; winner → 2× stake; no-show walkover → present writer 2× stake, no-show forfeits. ELO updates only when both writers are human AND is_ranked=true — standard formula, K-factor 32. Bot opponents always force coin_stake=0 + is_ranked=false (the long-standing rule symmetric with the contest bot-winner guard); a stake-queued writer hitting AI fallback at 4h has their stake silently waived. Permanent ranked-optout flag on profiles for writers who tried ranked + bounced off ELO loss-aversion. Frontend: stake selector chips (None / 25 / 50 / 100 / 250) + Casual/Ranked toggle in the queue card; balance displayed inline; submit button disabled when stake exceeds balance. Stats chip surfaces ELO + ranked W/L when the writer has any ranked history. The /api/cafe/battles/reviewer-prefs endpoint extended to return elo, ranked_*, ranked_optout, coin_balance.
  • Cafe-battle Realtime + leaderboard + writer-hub badges (migration 180 + leaderboard route + writer-hub chip): three Phase-2 follow-ups on top of the Phase 1 battle loop. (1) Supabase Realtime — migration 180 adds cafe_battles and cafe_battle_submissions to the supabase_realtime publication; the watch page (/write-cafe/battles/watch) and the battle room (/write-cafe/battles/[id]) both subscribe to postgres_changes UPDATEs filtered by battle_id. Spectators see body updates character-by-character (subject to the 700ms autosave debounce, tightened from 1500ms). Writer view does NOT splice opponent body from Realtime payloads — any change just triggers a reload() which routes through the backend's role-aware getBattleDetail() that strips opponent body until close. Slow polling (20-30s) kept as a fallback. Privacy posture is intentionally soft: no RLS, opponent body is technically subscribable; the writer's UI doesn't surface it. (2) Public leaderboardGET /api/cafe/battles/leaderboard returns top 20 writers (by cafe_battle_writer_points desc, wins tiebreak) and top 20 reviewers (by cafe_battle_reviewer_points desc). AI personas filtered defensively. Two-column section on the battles landing, visible to anyone. (3) Writer-hub battle chip/writers/[handle] header now surfaces ⚔️ 5W / 2L · 60 pts · 📜 25 reviewer pts when the writer has any battle history. Each segment hides at zero so brand-new writers' hubs stay clean.
  • Cafe-battle hardening pass — persistent state + cron-pulled bot fallback + no-show forfeiture + public closed view (cafe-battles.ts service + middleware): four operational gaps closed before Phase 2. (1) pingSpectator() and demo-cooldown moved to system_heartbeats rows (keys: cafe_battle_spectator_active + cafe_battle_demo_started) so they survive Railway redeploys + work across multi-instance fan-out. No new migration — reuses the heartbeats table from migration 175. (2) Bot-submission fallback — every cron tick scans is_bot=true submissions whose battle has been paired ≥ 15 min with empty body and re-fires the persona action. Catches the case where setTimeout-sleeping bot writes are dropped on a process restart. Idempotent via submitted_at short-circuit (~$0.10 worst-case race). (3) Writer no-show forfeiture — at 4h post-pair, the cron checks each battle: if a writer's started_at matches the placeholder + body is empty, they no-show. Both no-show → cancelled + refund both stakes; one no-show → walkover (opener wins 100-0, no-show forfeits stake). Bots exempt. (4) Public closed-battle view/write-cafe/battles/[id] falls back to GET /:id/public for unsigned-in users (returns 403 for in-flight battles, full transcript when closed/cancelled). Share URL pattern for non-cafe-members.
  • Cafe writer-vs-writer battles + spectator gallery (migration 179 + cafe-battles.ts service + cafe-battles.ts route + persona-actions/cafe-battle-actions.ts + /write-cafe/battles + /write-cafe/battles/[id] + /write-cafe/battles/watch): real-time-ish 1v1 writer duels on the same bento. Phase 1: async + casual. Writer enters queue → cron pairs with another queued writer (or AI persona after 4h fallback) → both get an email + battle URL → each has a per-writer 20-min clock (starts when they open the room, debounced auto-save, paste-blocked-style frozen-after-expiry semantics). Both submit → status judging → 3 random opted-in cafe members invited via email (24h to vote) + multi-critic LLM judge (Beatrice + Hugo, scoring on their dimensions) → final blend (50% LLM/50% humans at 3 votes, 80%/20% at 1-2, 100%/0% at 0) → winner declared, stats stamped. Bot opponents never involve coin stakes — kept symmetric with the contest's bot-winner guard. Spectator gallery at /write-cafe/battles/watch shows the current in-flight battle's two stories side-by-side at slight opacity ("watching from across the room"), polls every 4s; pingSpectator() flag is bumped each fetch and read by the demo cron. Bot-vs-bot demo battles auto-spin when (a) a spectator has been active in the last 30 min AND (b) no in-flight battle exists AND (c) cooldown elapsed (1h between demos). Two random is_active personas, fresh bento, both sides marked is_bot=true so casual W/L stats stay clean. New cron cafe_battle_tick (every 2 min) handles all four lifecycle transitions + heartbeat-tracked. Profile flags: cafe_battle_reviewer_opt_in (default false), cafe_battle_writer_points, cafe_battle_reviewer_points, cafe_battle_wins, cafe_battle_losses. Deferred to Phase 2: ELO + leaderboard, real-time concurrent typing via Supabase Realtime, coin stakes (human-vs-human only), tournaments.
  • Cafe writing → journal "write.cafe" notebook (migration 410 + services/cafe-journal-sync.ts): every contest submission + finalized battle entry is auto-copied into a HiveJournal notebook of the writer's named write.cafe (find-or-create by (user_id, name), mirroring getOrCreateGrapheneNotebook), so their cafe work lives in their journal. write.cafe + hivejournal.com share the same Supabase auth user, so the submission's writer_user_id owns the entry. The entry is private (*_visibility='user'), tagged ['write.cafe', 'contest'|'battle'], and carries a clearly-delimited prompt footer (song + ingredients + a back-link). Idempotent via cafe_contest_submissions.journal_entry_id / cafe_battle_submissions.journal_entry_id (migration 410): an editable contest re-submit UPDATES the same entry rather than duplicating; a battle finalize copies once; if the user deletes the entry a later re-submit re-creates it. Hooked fire-and-forget at the end of submitStory() (contest) and finalizeBattleSubmission() (battle) — best-effort + self-logging to service_errors (cafe-journal-sync), never blocks the submit. AI-persona (is_bot) battle entries are skipped.
  • Per-writer reader's notes from critic personas (migration 177 + cafe-persona-notes.ts service + FeedbackPrefsToggle component): when a cafe contest closes, every writer who has flipped cafe_critic_feedback_opt_in = true on their profile gets a personal Markdown note from one of the persona-attached critics (Beatrice, Hugo, …) — quoting specific lines, naming what landed and what to look at again, signed off in the persona's voice. Stored on cafe_persona_notes with a unique constraint on (submission_id, persona_key) so close-time triggers are idempotent. Persona is picked deterministically per (writer_user_id, week_start) so the same writer's resubmissions in one week stay with the same reviewer + different weeks get different reviewers (rotation). Safeguards baked in: opt-in default false, in-app only by default (separate cafe_critic_feedback_email_optin flag for email later), one note per submission per persona, generated only on close (no drip cadence), bot submissions skipped, prompts forbid scoring/ranking/AI-disclosure/moralizing. Cost: ~$0.01 per note (gpt-4o-mini). Auto-fires alongside audio + Shorts gen in transitionStatus's closed branch. Backend: GET /api/cafe/contests/:id/my-notes returns the writer's notes with persona display info; POST /api/cafe/contests/notes/:noteId/mark-viewed for the "new" badge. Frontend: MyPersonaNotes renders inline on the writer's /write-cafe/contest submission view when status is closed/paid; FeedbackPrefsToggle on /write-cafe lets the writer flip the opt-in.
  • Cafe book shelf — DB-backed, admin-curated, ~30 titles (migration 178 + cafe-books.ts service + /dashboard/admin/cafe/books): the carousel from the prior pass moved off the code-defined CAFE_BOOK_LIBRARY constant onto the new cafe_books table. Source-of-truth is now the DB; the in-code seed list runs idempotently on backend boot (insert-when-missing, never delete) so a fresh deploy is populated and removing a book from code does NOT clobber admin curation. Public GET /api/cafe/books returns active rows only; admin routes under /api/cafe/books/admin/* (super-admin gated) handle list-all + create + patch + delete + reseed. Admin page at /dashboard/admin/cafe/books is the curation surface — add a book by ASIN+ISBN+category+blurb, hide one, mark a "★ pick" of the week (the carousel renders a corner badge for featured rows), set sort order. Library expanded from 15 → ~30 titles spanning craft (Reading Like a Writer, The Anatomy of Story, Wired for Story, Stein on Writing, How to Write One Song), memoir-on-writing (The Writing Life, Letters to a Young Poet, Why I Write, Slouching Towards Bethlehem), short fiction (Munro, Joy Williams, Best American 2024), poetry (Felicity, The Carrying), creative life (Steal Like an Artist + the rest of the Kleon trilogy, Atomic Habits, The Artist's Way). Frontend: BookCarousel component now fetches from /api/cafe/books (with optional initialBooks prop for SSR fast-path); /write-cafe/books server-component fetches once and renders a carousel per category. Same hivejournal-20 Amazon affiliate tag as before. Click-through dashboard: the admin page surfaces total clicks across the shelf + top-5 books by click_count + a By-clicks sort toggle that orders rows by performance, and each row shows its individual click count. Carousel disclosure now uses the canonical FTC Amazon Associate phrasing via the shared <AmazonAffiliateDisclosure /> component (three style variants: default / cafe / inline).
  • Cafe book carousel — Amazon-affiliate writing-shelf (cafe-book-library.ts + BookCarousel component + /write-cafe/books page): a curated, code-defined library of 15 craft + creative-life books (On Writing, Bird by Bird, Story, Save the Cat Writes a Novel, etc.), grouped by category (Craft / Memoir on writing / Poetry / Short fiction / Creative life / Business of writing). Carousel mounts on the cafe home (single horizontal shelf with category-chip filter) + a dedicated browse page at /write-cafe/books shows every category as its own row. Each book card links to Amazon's product page via ASIN with the existing hivejournal-20 affiliate tag (same NEXT_PUBLIC_AMAZON_AFFILIATE_TAG env override the Open Energy materials use); covers fetched from Open Library's free covers API by ISBN (no API key, no rate limit). Visible affiliate disclosure rendered below the shelf — purchases fund the contest prize pool. Curation lives in code, not a DB; future iteration could move to admin-editable rows but the small-and-opinionated shelf is the point.
  • Bento song lookup links (SongLookupLinks component): seed pool stores titles + artists only, not Spotify track IDs, so an embed iframe isn't possible — the component renders "🎧 Listen — Spotify · YT Music · Apple" search-link buttons that deep-link the user's preferred service to the title+artist query. When a contest's prompt_song_url is set (admin pasted an explicit Spotify track URL on a custom week), an extra "Track" button surfaces it as a primary one-click listen. Mounted on /write-cafe/random-prompt, /write-cafe/contest, /write-cafe/contest/[weekStart], and the BentoOfTheWeek widget so anywhere a song appears, listening is one click away.
  • AI personas as ops reporters + critic voices (migration 176 + ops-personas.ts + ops-weekly-report.ts + script-critics.ts + /dashboard/admin/ops-reports): two ways the platform now uses AI personas to make machine output feel like it comes from someone. (1) Marlow — an ops persona who reads runHealthChecks() + the week's cafe contest activity every Sunday at 9am UTC and writes a Markdown note in his voice ("INTJ · Type 5w6 — quiet, careful, names systems by their actual names, distinguishes 'concerning' from 'notable'"). Cron fires every 6h, idempotents on (week_start, persona_key), only does LLM work when it's Sunday and this week's report doesn't exist yet. ~$0.02/wk. Heartbeat-tracked as ops_weekly_report. Admin page at /dashboard/admin/ops-reports lists past notes (markdown-rendered), with "Generate now" + "Force regenerate" buttons. Persona pattern mirrors SCRIPT_CRITICS — code-defined map (OPS_PERSONAS) keyed by stable string id, no FK to ai_personas (no auth user needed). Default persona via OPS_WEEKLY_REPORT_PERSONA env var; defaults to marlow. (2) Critic personas — added optional persona: { display_name, tag_line, voice_summary } to ScriptCritic. When set, the system prompt opens with "You are Beatrice — the Tension Editor (plot architect — believes every scene needs a turn). You speak plainly, weigh specificity over consensus..." instead of "You are Tension Editor." Wired on tension_editor (Beatrice) and dialogue_doctor (Hugo) as initial demos; remaining critics keep the un-personified prompt. New GET /api/admin/script-critics endpoint surfaces persona display info so future UI can render "Beatrice flagged this paragraph" instead of "tension_editor: minor". Same critic key keeps stored findings stable across persona swaps; data is keyed on critic_key, not the persona. Per-writer DM-style check-ins from these personas deliberately deferred — high tempting-feature risk of feeling intrusive or under-used.
  • System health / maintenance dashboard (migration 175 + system-heartbeats.ts service + system-health.ts service + /dashboard/admin/system-health): red/yellow/green status board for everything that should be running on a regular cadence. Distinct from the older /dashboard/admin/health (which audits user-engagement pipelines: payments flowing, emails landing, etc.) — system-health is the "is the platform plumbing intact" view. Three flavors of check: (1) Cron heartbeatssystem_heartbeats table (one row per task, upsert by name) records last_run_at + last_status + metadata on every fire. Stale > 1.5× expected interval = yellow, > 3× = red. Wired into the existing 15-min cafe-contest tick, 15-min welcome-drip tick, and 4-hour season auto-rerender scan via recordHeartbeat({ name, status, error?, expectedIntervalSeconds, metadata? }). The withHeartbeat() wrapper times an async block and records both ok and error paths so a new cron can't forget the error case. (2) Live probes — auth'd round-trips to OpenAI, ElevenLabs, Stripe, Resend, Bluesky (createSession), plus an env-var presence check for YouTube OAuth. 5-second per-probe timeout so a hanging service can't stall the dashboard. (3) Data state — current week's contest exists?, latest contest has 5 of 5 bento images?, latest closed has audio + Shorts for both winners?, Supabase Storage list works? Plus a config-presence panel for the env vars that must be set. Backend route GET /api/admin/system-health (super-admin gated) returns { overall, generated_at, checks[] }; frontend renders grouped tiles with manual refresh, summary counts, and per-check metadata expand. Linked from the admin home under Operations as "🚦 System health".
  • Pipeline + cron failures land in /errors automatically (2026-05-16): two indirect call paths feed recordServiceError so individual catch blocks don't have to. (1) EventTracker.failWithError(err, opts?) in season-pipeline-events.ts does both tracker.fail() (runs feed) AND recordServiceError() (daily triage) in one call. Defaults derive service/context from the tracker's stage, so most callers are a one-line replace of tracker.fail(msg)tracker.failWithError(err). 20 sites swept in routes/story-seasons.ts (audio render, script gen, trailer, podcast upload, score-music, generate-chapter, improve-all-behind, etc.). (2) recordHeartbeat({ status: 'error', ... }) in system-heartbeats.ts auto-bridges to recordServiceError with service='cron-<name>' — every cron's failure path picks this up with zero opt-in. Optional errorObj field preserves the stack trace (withHeartbeat() already forwards it; lower-level recordHeartbeat callers can opt in by passing it). 'partial' status intentionally excluded to avoid double-counting per-item failures that already log individually. Net: every backend pipeline + every backend cron now lands its failures in /errors. Before this, today's silent regenerate failure ([bulk-improve] "Season not found" on 4 chapters) left zero trace in the daily triage table.
  • Self-logging service-error console (migration 190 + migration 191 + service-errors.ts service + routes/service-errors.ts + /dashboard/admin/errors): catch blocks across auto-universe-batches, short-stories, and short-stories-audio call recordServiceError({ service, context, error, metadata, severity? }) so failures persist past the Railway log retention window and surface on the admin dashboard grouped by (service, context, message). Read-side listGroupedErrors() buckets the latest 1000 rows, computes count + unresolved_count + first_seen + last_seen + 3 sample IDs/metadata blobs per group; severity escalates within a group (any critical row makes the whole group critical). Per-group Resolve button bulk-stamps resolved_at + resolved_by_user_id; Mute (24h / 7d / forever) writes to service_error_mutes so the group hides from the default view while errors continue to be inserted (count + last_seen still update for audit). Source filter pill switches between Backend / Frontend / All. Frontend errors land in the same table<ClientErrorReporter /> mounted via ClientOnlyComponents hooks window.error + unhandledrejection; explicit catch sites can use reportClientError(). Two-layer noise filter (client-side skip + server-side reject) handles cross-origin Script error., ResizeObserver-loop, browser-extension URLs, AbortError, network flap. Server throttles >20 inserts of the same group within 5 min so a looping client bug can't drown the table. Routes: GET /api/admin/errors?include_resolved=&include_muted=, POST /api/admin/errors/resolve, POST /api/admin/errors/mute, POST /api/admin/errors/unmute, POST /api/admin/errors/client (auth-required, NOT super-admin), GET /api/admin/errors/:id. Daily-triage skill at .claude/commands/errors.md: /errors lists the top active groups, reads the relevant source files, and proposes per-group fixes (resolve / mute / fix-now). Linked from the admin home under Operations as "🚨 Service errors" + the FeatureMenu Admin category.
  • Email URL canonicalization + Vercel-preview redirect (apps/backend/src/utils/site-urls.ts + apps/frontend/src/middleware.ts): a graphene subscriber email shipped with https://hivejournal-...point-seven-studio.vercel.app/seasons/... because earlier URL helpers fell back to ALLOWED_ORIGINS (a CORS allowlist that includes rotating preview hosts) and FRONTEND_URL (set to a preview alias on Railway). Fix: new site-urls.ts provides hivejournalOrigin() / grapheneOrigin() / writeCafeOrigin() / seasonUrl() that reject *.vercel.app outright and route season URLs to graphene.fm. Updated all email + RSS call sites: season-subscribers.ts, welcome-drip.ts, graphene-subscribers.ts, creator-notifications.ts, season-letters.ts, routes/podcast.ts. Belt-and-suspenders: the frontend middleware now 308-redirects any *.vercel.app host to the canonical brand domain (path-aware — /seasons/* → graphene.fm, /write-cafe/* → write.cafe, else hivejournal.com), so old email links still resolve on any deployment that includes the middleware. Do not use process.env.ALLOWED_ORIGINS to build user-facing URLs — it's CORS-only.
  • CORS allows platform brand origins by default (apps/backend/src/index.ts): the six canonical brand origins (apex + www for hivejournal.com, write.cafe, graphene.fm) are hard-coded into PLATFORM_ORIGINS and prepended to whatever ALLOWED_ORIGINS env var specifies. Earlier code required every origin to be in ALLOWED_ORIGINS, so a missing/truncated env var quietly broke fetch calls from the brand domains to the backend (preflight blocked with "Response to preflight request doesn't pass access control check"). Brand domains are part of the platform, not per-env config — they should never depend on a Railway env being set right.
  • Cross-brand navigation must use absolute URLs (WriteCafeHeaderBrand.tsx + GrapheneHeaderBrand.tsx + docs/setup-guides/MULTI_BRAND_DOMAINS.md): the brand-switcher dropdown's "HiveJournal", "Graphene", and "write.cafe" entries previously used <Link href="/"> / <Link href="/graphene"> / <Link href="/write-cafe">. Those routed through the host-aware middleware and stayed on the same brand surface — clicking "HiveJournal" on write.cafe loops you back to write.cafe/write-cafe. Fix: every cross-brand jump uses absolute URLs (https://www.hivejournal.com, https://www.graphene.fm, https://write.cafe). Next.js Link handles absolute external URLs as plain <a> tags (no prefetch, same-tab nav). Same gotcha applies to any future cross-brand link — see the multi-brand guide.
  • Cafe visual surfacing — fireplace backdrop, writing-desk imagery, "Start writing" CTA (CafeBackdrop.tsx + /write-cafe/page.tsx + /write-cafe/contest/page.tsx): the cafe pages share a fireplace /landing/fireplace.mp4 backdrop with a dark amber radial overlay (CafeBackdrop component) so write.cafe / sprints / contest / random-prompt / archive / per-week / embed-docs / badge-docs all feel like the same warm room. Submit-form section on /write-cafe/contest gets an additional sepia notebook-on-wood image (/images/screen-backgrounds/50260323_l.jpg) layered behind the textarea so the moment-of-writing reads as a physical desk. Hero on /write-cafe has a primary "✏️ Start writing →" CTA that lands signed-in writers in /dashboard/entries/new?source=write-cafe (the entry editor that feeds back into the cafe word-count integration); signed-out users get bounced through /auth/signup with the editor as next. Secondary "🎲 Roll a prompt" link drives the random-bento generator. Backdrop opacity tuned (radial center 0.72, edges 0.97; per-form panels override with bg-amber-950/85 backdrop-blur-md to stay legible).
  • Pinterest pins for cafe contests (/api/og/cafe-bento-pin/[contestId] + /api/og/cafe-winner-pin/[contestId]/[category]): two new edge OG endpoints rendering 1000×1500 (2:3) Pinterest-shaped images. (1) Bento pin — top-heavy layout (mark + song + 4 ingredients + prize/CTA at bottom) so the visual hook lands above Pinterest's feed crop line. (2) Winner pin — trophy + writer name + category + votes + the bento that produced the win, same top-heavy structure. "📌 Pin it" buttons on /write-cafe/contest/[weekStart] and /cafe-winners/[contestId]/[category] deep-link Pinterest's /pin/create/button/?url=&media=&description= so the user lands on Pinterest's "create pin" form pre-filled with our image + text. Distinct from the 1200×630 cards (Twitter/Bluesky/Facebook) and 1080×1920 stills (YouTube Shorts) — same data, third aspect for the third surface.
  • Bluesky autopost for cafe contests (migration 174 + cafe-bluesky.ts service + admin routes + dashboard buttons): three-gate feature flag — BSKY_HANDLE + BSKY_APP_PASSWORD env vars enable the integration (admin-trigger only), BLUESKY_AUTOPOST_ENABLED=true flips on auto-fire from transitionStatus. Without creds the whole feature stays dark and entry points silently no-op. Speaks AT Protocol XRPC over fetch directly (no @atproto/api dep) — createSession (app-password login) → optional uploadBlob for image embeds → createRecord for the post. Posts are idempotent on bluesky_post_uri columns (added on cafe_contests for the Monday bento + cafe_contest_winners per-category for winner reveals). Two compose flows: (1) Monday bento post🍱 This week's bento at write.cafe / 🎵 song / setting+character+motif+object / prize+CTA / per-week URL / #writingcommunity #shortstory with up to 2 of the DALL-E bento images embedded (skipped silently if oversize the 1MB blob cap); auto-fires on submission_open after a 90s delay so DALL-E gen has time to land. (2) Winner reveal post — per category, 🏆 winner / bento song / votes+prize / week / share URL / #writingcommunity #shortstory with the existing 1200×630 cafe-winner OG card embedded as the unfurl visual; auto-fires on closed after audio + Shorts gen complete. Facets are computed for both URLs and hashtags so they render clickable (UTF-8 byte offsets, not character offsets — Bluesky requirement). Admin: GET /api/cafe/contests/bluesky/status reports configured + autopost flags so the dashboard can hide buttons when dark; POST /:id/post-bento-to-bluesky + POST /:id/post-winners-to-bluesky (with optional force / category body fields) for manual triggers; "🦋 Post bento to Bluesky" + "🦋 Post winners" buttons render on contest cards only when configured. AT-URI helper atUriToWebUrl() converts at://did:plc:.../app.bsky.feed.post/<rkey> to https://bsky.app/profile/<did>/post/<rkey> for clickable status links.
  • YouTube Shorts for cafe winners (migration 173 + cafe-winner-shorts.ts service + /api/og/cafe-winner-short/[contestId]/[category]): builds on the audio gen by combining a 1080×1920 vertical still card (rendered via @vercel/og at the new edge route — trophy + winner name + bento + week label) with the winner narration MP3 from migration 172, into a 60s-capped vertical MP4 via the existing bundled ffmpeg (-loop 1 -i still.png -i audio.mp3 -t 60 -c:v libx264 -tune stillimage -c:a aac -shortest -movflags +faststart). Uploads to season-assets/cafe-winners/<contestId>/<category>-short.mp4, stamps short_video_url + short_generated_at on cafe_contest_winners. Auto-fires sequentially after audio gen on transition to closed (Shorts depend on the audio file existing). Admin retry: POST /:id/regenerate-winner-short + "📹 Regenerate Short" button. Optional second step: uploadWinnerShortToYouTube({ contestId, category }) streams the MP4 to YouTube via the existing googleapis flow (#Shorts hashtag in title + description so YT classifies it correctly), stamps youtube_short_id. The YouTube path is feature-flagged on YOUTUBE_OAUTH_* env vars (same set the season pipeline uses) and exposed at POST /:id/upload-winner-short-to-youtube. Surfaces: per-week URL /write-cafe/contest/[weekStart] and the share page /cafe-winners/[contestId]/[category] render an inline <video controls> (capped at 280–320px wide so vertical doesn't dominate the page) with a small "On YouTube ↗" link when youtube_short_id is set.
  • TTS audio for cafe winners (migration 172 + cafe-winner-audio.ts service): when a contest transitions to closed (manual admin or cron), the close-side hook fires generateAllWinnerAudio(contestId) in the background. Per winner: pulls the winning submission body, calls ElevenLabs TTS via the existing ttsSegment helper (default voice = ElevenLabs "Rachel" 21m00Tcm4TlvDq8ikWAM, override via CAFE_WINNER_VOICE_ID env), uploads to season-assets/cafe-winners/<contestId>/<category>.mp3, and stamps audio_url + audio_generated_at on cafe_contest_winners. Long stories (>4500 chars) get a sentence-aware soft trim so narration never cuts mid-word. Best-effort throughout — per-row failures are logged but don't block contest close. Surfaces: per-week URL /write-cafe/contest/[weekStart] and the share page /cafe-winners/[contestId]/[category] render a native <audio controls> element when audio_url is set. Admin retry route POST /:id/regenerate-winner-audio (with optional category body field) + a "🎤 Regenerate audio" button in the admin contest dashboard for "the narration cut weird" / "ELEVENLABS_API_KEY wasn't set when close happened" cases.
  • Cafe embed widget + writer badge (/write-cafe/embed/this-week + /write-cafe/embed + /write-cafe/badge + /api/og/write-cafe-badge): two distribution surfaces that put write.cafe on partner sites and writer profiles. (1) Iframe embed/write-cafe/embed/this-week is an iframe-friendly minimal-chrome page rendering the active bento (with a "powered by write.cafe" footer linking back). Snippet generator at /write-cafe/embed shows a live preview + copy-paste HTML/Markdown snippets. The umbrella's persistent UI (Chatbot, JQ floating tooltip, version footer) is suppressed for any path under /write-cafe/embed or /write-cafe/badge via a CHROME_FREE_PREFIXES allowlist in ClientOnlyComponents — embeds shouldn't ship a JQ chat bubble. (2) Writer badge/api/og/write-cafe-badge?style=dark|light returns a 240×60 PNG ("I write at write.cafe" with the cup mark) generated edge-side via @vercel/og; /write-cafe/badge is the docs page with style toggle + HTML/Markdown/BBCode copy snippets. Free, no API key, no rate limit — earned-media bet that scales with the writer base.
  • Cafe contest discovery surfaces — random prompt, archive, per-week URLs, RSS, Reddit-paste tool (cafe-contests.ts route + /write-cafe/random-prompt + /write-cafe/bento-archive + /write-cafe/contest/[weekStart] + admin contest dashboard): five SEO + share surfaces that earn the contest non-paid traffic. (1) Public GET /api/cafe/contests/random-bento (was admin-only, was also dead because /random-bento got swallowed by /:id — both fixed) feeds /write-cafe/random-prompt, a free unlimited-roll writing-prompt generator built on the same seed pool the contest uses. (2) GET /api/cafe/contests/archive returns paginated past contests (closed/paid) with a thin slice of winners per row; powers /write-cafe/bento-archive — a card-grid wall of every bento ever rolled. (3) GET /api/cafe/contests/by-week/:weekStart resolves a contest by its ISO Monday date and returns the same {contest, submissions, winners} shape as /:id; powers /write-cafe/contest/[weekStart] — a stable per-week share URL (Twitter/Bluesky/Substack share posted Monday no longer becomes a different page next Monday) with full metadata (OG, Twitter, canonical) generated server-side. (4) GET /api/cafe/contests/feed.rss renders a 40-item RSS 2.0 feed pointing at the per-week URLs — feed-reader + RSS-aggregator surface. (5) Admin contest dashboard gets a "📋 Copy Reddit post" button per non-draft contest that puts a markdown-formatted post (title + body with bento + per-week link) on the clipboard, ready to paste into r/writing or r/writingprompts. All five public routes mounted BEFORE /:id in cafe-contests.ts so Express order-based matching doesn't read "random-bento" / "archive" / "by-week" / "feed.rss" / "email" as contest UUIDs. CafeBackdrop (/landing/fireplace.mp4 + dark amber overlay) wraps every cafe page for visual continuity.
  • Visual bento + cafe floor on /write-cafe (migration 171 + cafe-bento-images.ts service + BentoBoxVisual.tsx + BentoOfTheWeek.tsx + TablesView.tsx): two new home-page surfaces. (1) Visual bento box — when a contest is created, a background DALL-E 3 job generates one watercolor-ink illustration per ingredient (song / setting / character / motif / object / line — ~$0.25/contest), uploads to Supabase Storage (avatars bucket, cafe-bento/<contestId>/<kind>.png prefix), and persists the URLs on cafe_contests.bento_image_urls. BentoBoxVisual renders an SVG-framed lacquered-bento outline with <foreignObject> cells holding real <img> elements + ingredient labels (asymmetric 5-cell layout: large song cell + character + setting + motif + object/line). Cells fall back to text labels while images are still generating. Admin re-generation route at POST /api/cafe/contests/:id/regenerate-bento-images. Visual mounts on the /write-cafe home (above the floor view) AND on /write-cafe/contest (replacing the text-only prompt list as the hero). (2) Cafe floor (TablesView) — replaces the older flat-grid WritersRoomWidget with a literal floor-plan: 4 named tables (Window seat / Counter / Corner booth / Loft) with 2-3 writers seated each. Stable seating via writer-id hash so the same writer doesn't bounce between tables; spillover rolls to open seats; empty tables fill with ambient placeholders so the room never reads cold. Real cohort members + AI personas mixed; nothing about WHAT anyone is writing surfaces.
  • Per-season trigger warnings (migration 275 + season-trigger-warnings.ts + TriggerWarningChip + TriggerWarningsAdminEditor + lib/trigger-warnings.ts): listener-facing content warnings, distinct from ContentRatingChip (which is the age gate). story_seasons.trigger_warnings text[] holds canonical slugs (suicide / self_harm / violence / ai_unplugging / etc — 27 slugs across self-directed harm, inter-personal violence, identity-based harm, substance, mental health, death/grief, sex, AI-sentience, and other). Frontend registry owns slug → label + one-line description + severity tier (low/medium/high) that drives chip palette. <TriggerWarningChip> renders a single ⚠️ pill (severity-tinted) that expands on hover/click to the full list with descriptions; mounted on the season detail page + the read-page chrome bar. Super-admin editor <TriggerWarningsAdminEditor> lives above SuperAdminPanel: checkbox grid for manual entry plus a "🧐 Suggest from prose" button that runs Claude over all chapter prose (sampled to ~30k chars), returns suggested slugs + a per-slug reason; admin reviews + saves. Routes: POST /api/story-seasons/:id/trigger-warnings/suggest (super-admin, returns { suggested, no_prose, raw? }), PATCH /api/story-seasons/:id/trigger-warnings (super-admin, body { warnings: string[] }, validates each slug against registry, drops unknowns).
  • Age compliance — 13+ floor + per-season content rating (migration 170 + auth/signup checkbox + /terms / /privacy preambles + ContentRatingChip + AgeGate18Modal + admin dropdown in SuperAdminPanel): two-layer age compliance. Layer 1 (COPPA floor): 13+ self-attestation checkbox at signup; the Sign Up button is disabled until checked, signup refuses outright when unchecked. After successful signup the timestamp lands on profiles.age_attested_13plus_at (write-once via the existing PUT /api/user/profile). Terms + Privacy preambles ship a clear amber banner stating the 13+ requirement. Layer 2 (mature content): story_seasons.content_rating enum (general / teen / mature) defaults to general. The listener-side ContentRatingChip shows on poster cards + season heroes (suppressed at general). When a season is rated mature, the season detail page mounts AgeGate18Modal — a cookie-persisted (hj_18plus_attested, 90-day TTL) self-attestation modal that blocks the page until the listener affirms 18+ or routes back to /graphene. Admin sets the rating from a dropdown in the season's SuperAdminPanel config row, persisted via new PATCH /api/story-seasons/:id/content-rating. Self-attestation is the platform-floor pattern (Apple / Google / Stripe all run on it); DOB collection is more robust but creates its own data-minimization burden — flagged as future work for legal review.
  • Welcome drip (+0d / +3d / +10d email sequence) (migration 169 + welcome-drip.ts service + welcome.ts route + cron wired in backend index.ts): three-email signup drip with milestones at +0d (welcome / 3 places to start), +3d (featured Graphene season), and +10d (what's new + cafe contest reminder). Per-step welcome_email_N_sent_at timestamps on profiles + a single welcome_emails_optout exit door (default false). New 15-minute setInterval cron walks the three steps, finds users at each milestone with no send yet, sends, stamps. Idempotent re-runs; max 50 emails per step per tick to avoid burst-rate-limiting Resend on first deploy. Stateless HMAC unsubscribe link (signWelcomeUnsub / verifyWelcomeUnsubSignature) with its own secret namespace so the welcome and cafe-broadcast opt-outs can't cross-forge each other. Public unsubscribe at GET /api/welcome/unsubscribe?u=<userId>&s=<sig> flips the boolean.
  • Cafe contest winner share cards (/api/og/cafe-winner/[contestId] + /cafe-winners/[contestId]/[category] + extension to notifyCafeContestPhase's closed-phase email): when a contest closes, every winner email gets a "📣 Share your win" button pointing at a brand-aware HTML page whose OG metadata points at a 1200×630 trophy PNG with the writer's name + category + votes + prize + week. Pasting the URL into Twitter/Bluesky/iMessage/Slack unfurls the trophy card — earned media on every contest. The HTML page itself shows the bento, links to the writer's hub, and CTAs the next contest. PNG generated edge-side via @vercel/og from the existing public contest data; falls back to a neutral card when winner data can't load.
  • Cafe contest Monday-broadcast email (migration 168 + extension to creator-notifications.ts + cafe-contests.ts route): when a contest enters submission_open, Resend fires a broadcast to every prior cafe-contest participant who hasn't opted out. Recipients = cafe_contest_submissions writer_user_ids ∪ minus profiles.cafe_contest_emails_optout=true ∪ minus AI personas. Email body includes the bento (song + ingredients), prize, submit button. Stateless one-click unsubscribe via HMAC-signed link (signUserForUnsub(userId)verifyUnsubSignature(userId, sig)) → public route GET /api/cafe/contests/email/unsubscribe?u=<userId>&s=<sig> flips opt-out. Hooked into transitionStatus side-effects so manual admin transitions AND the cron path both fire the broadcast. The first contest broadcasts to no one (no prior participants); list grows weekly from there.
  • Persona-actions framework + AI personas auto-running cafe contests (persona-actions.ts + persona-actions/cafe-contest-actions.ts + extension to cafe-contests.ts): generic shape for AI personas to act on the platform on their own, plus a concrete first use case — auto-running the weekly cafe contest when humans are sparse. The framework: runAction(personaId, name, params) looks up the persona's context (user_id + MBTI/OCEAN/backstory/topics), dispatches to a registered action. Actions register themselves at module load via register(name, fn). Two cafe actions for v1: cafe_contest.submit_story (LLM-generates a 350-700-word story in the persona's voice given the bento prompt + personality, then submits via the same submitStory() humans use — so the row looks correctly like the persona did the thing) and cafe_contest.cast_vote (reads each un-voted ballot, asks the LLM to pick the one this persona would prefer with a "which moves you more" rubric anchored to personality, casts via castVote()). gpt-4o-mini for both — cheap (~$0.10 per submission, ~$0.05 per ballot pair). Three new orchestrators on top: ensureCurrentWeekContest() auto-creates a draft for current Monday with a randomized bento; populateContestIfShort({ contestId }) tops up ai_assisted submissions to the configured target (env: AI_PERSONA_BOT_TARGET_PER_CATEGORY, default 3); runBotVoting(contestId) fires votes on every persona ballot during review window. All three wired into runCafeContestTick (the existing 15-min cron) so the loop runs autonomously: cron creates Monday's draft, transitions it to submission_open, bots fill it mid-week, cron flips to review_open + assigns pairings, bots vote, cron flips to closed + computes winners + sends emails. Bot involvement gated by AI_PERSONA_BOTS_ENABLED=true env var (admin-curated weeks just unset it). Admin trigger routes for each step: POST /api/cafe/contests/ensure-current-week, POST /:id/populate, POST /:id/run-bot-votes. Bots default to the ai_assisted category — typed_only stays human-only on purpose.
  • write.cafe contest automation — cron + emails + Stripe payout (extends cafe-contests.ts + creator-notifications.ts + backend index.ts): the three follow-ups that close the contest loop end-to-end. (1) Cron tickrunCafeContestTick() evaluates every non-paid contest against calendar boundaries derived from week_start (Mon → submission_open → Sat → review_open → next Mon → closed) and transitions any that have crossed. Wired as a setInterval in backend startup (15-minute cadence, 2-minute boot delay); also exposed as POST /api/cafe/contests/tick for admin-triggered manual runs. Idempotent — transitionStatus's status check makes re-runs no-ops. (2) Email notifications — new notifyCafeContestPhase in creator-notifications fires on entry to review_open ("📜 Your ballots are ready — vote both rounds to stay eligible") and closed (per-recipient: 🏆 winner copy / forfeit copy / generic reveal copy). Hooked into transitionStatus side-effects so both manual admin transitions AND the cron path send emails. Same Resend-best-effort failure pattern as notifyTipReceived. (3) Stripe Connect auto-payout — new payContestWinner({ contestId, category }) fires stripe.transfers.create to the winner's Connect account, stamps paid_via_stripe_transfer_id + paid_at, then auto-flips the contest to paid once both categories are settled. Idempotent on paid_at; clean error when the winner has no active Connect (admin can chase manually + retry once they connect). Admin UI now has TWO buttons per winner — "Pay via Stripe" (primary, fires the transfer) and "…or mark manually" (fallback for Venmo / wire / cash payouts).
  • write.cafe weekly contest (migration 167 + cafe-contests.ts service + cafe-contests.ts route + admin page + /write-cafe/contest): bento-box weekly writing contest. Same prompt for everyone (1 song + 3-5 ingredients across setting/character/motif/object/line categories) → writers submit → peer-reviewed across 2 rounds → real-money prize. Two parallel categories (typed_only with paste-blocked editor + ai_assisted) so the AI question is two separate sports. State machine: draftsubmission_openreview_openclosedpaid, transitions admin-driven (no cron yet). Pairing assignment fires on entry to review_open: each writer gets 2 ballots (one per round), each ballot pairs 2 same-category submissions not their own; round 2 avoids stories the reviewer already saw in round 1 → each story ends up with ~4 reviewer-votes total. Eligibility-by-reviewing: writers who don't complete both ballots get is_eligible=false flagged at close + their submission is excluded from winner compute (solves the dead-weight-submission problem). Anonymity during review window enforced server-side: GET /api/cafe/contests/:id strips writer_user_id until status flips to closed. Prize is platform-funded for v1 — admin clicks "Mark paid" once Stripe Connect transfer is fired manually. Routes mounted at /api/cafe/contests: public /active + /:id + admin CRUD + writer /:id/submissions + /reviews/:reviewId/vote. Writer-facing page at /write-cafe/contest switches phase by current contest status: prompt + submit form during open window; ballot pair cards with vote buttons during review; last-week's winners always rendered below.
  • Personal Drift wallet at /dashboard/drift (apps/frontend/src/app/dashboard/drift/page.tsx): personal coin-economy surface — no schema or backend changes, reuses existing GET /api/drift/wallet + GET /api/drift/wallet/transactions?limit=50. Renders a headline balance with lifetime earned/spent counters, two earn/spend tip cards pointing at /graphene + general behavior, and a recent-transactions list with reason-coded emoji badges (treasure_claim 🎁 / fork_spend 🌀 / chapter_unlock 🔓 / coin_tip 💸 / coin_purchase 💰 / admin_grant ✨ / admin_clawback ↺). Season titles are already hydrated by the transactions endpoint so rows read "Tipped Iron Hour Ch 3" instead of a UUID. Nav entry "Drift wallet" added under Stories in FeatureMenu.tsx.
  • Drift coin chapter unlock (migration 166 + drift-coin-spend.ts unlockChapterWithCoins + drift.ts routes + ChapterPlaylistPlayer): listener spends 50 coins (= $5 nominal, matches Graphene+ monthly headline) to permanently unlock a single locked chapter without subscribing. Third gate alongside Graphene+ subscription + the 7-day free window. New drift_chapter_unlocks table with UNIQUE(user, season, episode) makes the unlock idempotent. Service unlockChapterWithCoins mirrors spendCoinsAsTip: debits wallet → writes creator_earnings (drift_currency) at 90/10 → fires the writer's tip-received email. Routes: POST /api/drift/seasons/:id/chapters/:episodeNumber/unlock + GET /api/drift/seasons/:id/my-unlocks. UI: secondary "🪙 Or unlock just this one with N coins" link inside the existing LockedChapterCard — primary subscribe CTA stays loud; coin path is the secondary "I just want this chapter" affordance. Cost is fetched from the existing packages endpoint so the headline stays in sync with DRIFT_CHAPTER_UNLOCK_COIN_COST if we iterate price.
  • Listener support dashboard (/dashboard/my-support) (backend support-summary + page.tsx): mirror image of the creator earnings page — shows the listener what they've sent OUT to writers across all rails. Four summary tiles (sent in tips · coins spent · writers supported · coins bought) + a unified activity feed merging episode_tips + drift_transactions (coin_tip / chapter_unlock / fork_spend) + drift_coin_purchases, sorted by date. Each row shows show + writer (linkable to /writers/[handle]) + relative timestamp. Empty-zero state lands gently — fresh listeners aren't shamed for not having tipped yet. Backend GET /api/user/support-summary does one batched lookup for season titles + writer names (no N+1) and returns aggregated totals + the top 50 events.
  • Tip activity "From" column on creator earnings (dashboard/creator/earnings): adds an anonymized "Listener #abcd" label (first 4 chars of related_user_id) to the earnings ledger table so writers can spot recurring supporters without the platform leaking real identifiers. Same listener = same hash across rows; pool allocations and anonymous-checkout tips render as a neutral em-dash. The hash is one-way (listener can't be looked up from it) — privacy preserved while still surfacing the rhythm of incoming support.
  • Public supporter social-proof on writer hubs + season heroes (SupporterChip): aggregate-only stats — "💸 12 supporters · $X sent · last tipped 3h ago" — render at the moment-of-decision on /writers/[handle] (muted purple variant) and /seasons/[id] (warm amber variant alongside the Support button). Privacy-safe by design: never displays individual tipper identities, only counts + a relative timestamp. Backend rolled into the existing GET /api/writers/:handle and GET /api/story-seasons/:id responses (no extra round-trip for the page) — both filter creator_earnings to tip + drift_currency sources (subscription_pool excluded since pool allocations aren't 1-listener signals) across pending / transferred / platform_retained rows. Hides at zero so an unsupported page doesn't read as empty.
  • Creator getting-started checklist (/dashboard/creator + creator-payouts.ts onboarding-status): walks a writer like Amy from "I just signed up" to "tips can flow" with a concrete 7-step checklist. New GET /api/creator/payouts/onboarding-status endpoint rolls up every check (display name, handle, Stripe Connect, first show, Drift enabled, treasures planted, bio + hero) in a single round-trip. Each step shows ✓/☐ + a one-click CTA so there's no ambiguity. Money-on-hold banner surfaces when there's platform_retained earnings AND Connect isn't done yet — concrete dollar amount creates urgency around the Connect step. Progress strip at top + earnings dashboard link at bottom for once they're past Connect.
  • Tip notification emails (creator-notifications.ts + hooks in episode-tips.ts + drift-coin-spend.ts): when a creator's creator_earnings row lands as paid (Stripe tip via webhook OR Drift coin tip), Resend fires an email to the creator's auth email — subject "💸 You got a tip on <show> Ch <N>" or "🪙 You got a coin tip on …" depending on source. Email includes gross / creator-share / earnings-dashboard link. Best-effort: any send failure is swallowed so a Resend hiccup never breaks the underlying tip flow. Closes the feedback loop — writers feel earnings arrive → share their writer hub more → flywheel. In-app inbox UI is a separate slice.
  • Brand-curated footers on /graphene + /write-cafe + /seasons/* (BrandedFooter.tsx + GrapheneFooter + WriteCafeFooter): replaces the umbrella HiveJournal SiteFooter (which links to every public surface — features, dreampro, hum, learn, blog, etc.) with brand-specific footers on the cinematic surfaces. BrandedFooter is a small client switcher reading useBrandedSurface to pick: write.cafe → WriteCafeFooter; graphene/seasons → GrapheneFooter; everything else → SiteFooter. Each brand footer lists only its own destinations (Graphene: All shows / Universes / Graphene+ / Podcast RSS; write.cafe: Cafe / Sprints) plus Account + Legal columns and a small "part of HiveJournal" credit at the bottom that links to the umbrella without surfacing the full sitemap. Replaces SiteFooter mounts in GrapheneClient + SeasonClient; /write-cafe gets its first footer (the page didn't have one before).
  • Writer profile polish — username slug, bio, hero banner (migration 165 + writers.ts + /dashboard/settings + /writers/[handle]): three new columns on profiles (username, bio, hero_image_url) turn the existing /writers/[userId] hub into a real artist page. Username is a lowercase slug (3–30 chars, alphanumeric + dash + underscore, partial-unique-index on lower(username) so the NULL majority doesn't collide); bio caps at 1000 chars in DB / 280 chars in UX; hero_image_url is a Supabase Storage public URL (re-using the existing avatars bucket with a hero/ path prefix so no new bucket provisioning needed). Route param renamed [userId][handle]; backend resolver detects UUID-vs-slug and maps both to the same writer record. New GET /api/writers/check-username/:username endpoint drives a debounced (350ms) availability indicator in the settings UI; canonical-URL rewrite on the writer page swaps a UUID URL to the slug form via history.replaceState once the writer claims a handle, no page reload. The season detail "by [writer]" credit prefers owner_username over owner_user_id so share links default to the prettier URL. New "Writer profile" card in /dashboard/settings handles hero upload (mirrors the existing avatar pattern), handle picker, bio textarea, and a "View public page ↗" link once a username is set.
  • Public writer profile (/writers/[userId]) (backend writers.ts + page.tsx): Patreon-style hub listing every season the writer has owned + published. Each show card has its own 💸 Support button that opens SupportWriterModal scoped to that show — tips/coin-tips need a season+episode to attach to, so a per-show CTA is more honest than a writer-level "tip me" button that secretly picks a show. URL uses the auth user_id directly (mirrors the existing /actors/[userId] pattern); a friendlier slug-based URL can come later once profiles get a username column. The season detail hero now renders a small "by [writer]" credit when the season has owner_user_id + the owner has set a display name (full_name or pseudonym), linking back to the writer's hub. Backend's GET /api/story-seasons/:id was extended to include owner_display_name in the season payload (one extra profiles round-trip on the season fetch — cheaper than a second client-side request) so the credit doesn't need its own client call.
  • Support-the-writer surface on the season hero (SupportWriterModal + wiring in SeasonClient.tsx): unified entry point exposing all three monetization streams (one-time tip 80/20, Graphene+ subscription pool 70/30, Drift coin tip 90/10) at the top-of-funnel — visible to every visitor on every season page, not just those who finished a chapter. Click "💸 Support the writer" → picker modal → select option → opens the appropriate downstream flow (TipChapterModal / GraphenePlusModal / CoinTipModal) with the season's latest published episode pre-selected so the writer earns on a chapter the listener actually heard. Coin-tip option only surfaces when the season has drift_enabled so the picker never offers a dead-end. Picker gated by tipping_enabled site-setting (same as the chapter-footer tip CTA) and presence of at least one published chapter. New support_writer value added to GraphenePlusModal's source prop so funnel analytics can break out subscriptions originated from this picker vs. the locked-chapter affordances.
  • Apple Notes import (journal-import.ts + /dashboard/import/apple-notes): closes the export gap Apple deliberately leaves open. The user runs a small JavaScript-for-Automation (JXA) script in Script Editor that walks every note in Notes.app and writes ~/Desktop/apple-notes-export.json with { title, body (HTML), folder, created_at, modified_at } per note. They upload that JSON; backend strips the HTML to plaintext via a focused appleNotesHtmlToText helper (handles <p>, <div>, <br>, <li>, the entity escapes Apple Notes actually emits — good enough for v1; can swap in turndown later if creators complain about formatting loss), creates a single "Apple Notes Import" notebook, and inserts each note as a journal_entries row with the original timestamps preserved + folder-as-tag (skipping the default "Notes" folder so every entry isn't carrying the same noise tag). Locked notes are skipped by the script (Notes.app doesn't expose body via automation); empty notes are skipped backend-side. Route: POST /api/import/apple-notes (multer file upload). UI: dedicated page with a copy-able script block, step-by-step Script Editor walkthrough (set Language to JavaScript, paste, ▶ Run, grant permission once), and a file uploader. Linked from /dashboard/import as a separate card alongside Day One / Journey / CSV.
  • Drift creator-scoped curation (drift.ts route): treasure-planting, treasure-edit, and decision-point CRUD were super-admin only in v0; now also pass for the season's owner via a new requireSuperAdminOrSeasonOwner(resolveSeasonId) factory. Four pre-bound resolvers: requireSeasonOwner (route param IS the season id), requireTreasureOwner (resolve drift_treasures.season_id), requireTakeOwner (resolve story_episode_takes.season_id), and requireDecisionPointOwner (resolve drift_decision_points.season_id). The POST /decision-points route resolves the season inline because the season id lives in the request body, not a route param. The in-prose ChapterSelectionMenu "🪙 Plant Drift treasure" affordance was already shown for canMarkup (admin OR owner) — now that the backend gate matches, creators can actually plant treasures on their own shows. Other creators still get 403 — one creator's prose isn't editable by another.
  • Drift wallet drawer — listener-side audit history (DriftWalletDrawer.tsx): the 🪙 wallet pill in the read-page header now opens a slide-in drawer instead of jumping straight to the buy modal. Drawer shows hero balance + lifetime earned/spent, a "Buy more coins" CTA that hands off to BuyCoinsModal, and a paginated transaction history with reason-aware chips (⚱️ Treasure / 🛒 Purchase / 🎁 Granted / ↩️ Clawback / 🪙 Coin tip / 🌿 Fork / 🔓 Branch unlock). Each row shows the related season title (when applicable), the delta with sign-coloring (green earn, red spend), and a relative timestamp. The GET /api/drift/wallet/transactions endpoint now joins story_seasons.title server-side via a single batched in-clause so the drawer can render "Tipped Iron Hour Ch 3" instead of raw UUIDs without an N+1. Pagination uses before (ISO timestamp of the oldest row) so "Load more" stays cheap.
  • Earnings dashboard — per-source breakdown + colored chips (dashboard/creator/earnings): the creator earnings ledger now visually distinguishes revenue streams. Source column is a SourceChip (💸 Chapter tip / 🪙 Drift coin tip / 🎧 Subscription pool / 🤝 Sponsorship / ✋ Manual grant) with per-stream tinting. The GET /api/creator/payouts/earnings summary now includes a by_source rollup (count + creator_cents + gross_cents per source_kind), driving a BySourceStrip above the table that surfaces each populated stream as its own tile — only renders when ≥2 streams are populated, otherwise the SummaryCards already cover it. The "drift_currency" label was renamed to "Drift coin tip" so creators see the spend mechanism, not the ledger column name.
  • Drift coin SPEND — listener tips writer with coins (90/10 split fires) (drift-coin-spend.ts): closes the loop on the in-app currency. Listener taps 🪙 Tip in coins at the chapter footer (next to the Stripe-tip 💸 button), picks 25 / 50 / 100 coins, and the wallet debits while a creator_earnings row writes at the configured drift_creator_pct (default 90% — see creator_revenue_splits.drift_creator_pct in migration 160). No new schema needed; reuses the existing drift_transactions ledger (with reason='coin_tip') and creator_earnings (with source_kind='drift_currency'). Coin-to-cents conversion uses a fixed NOMINAL_COIN_VALUE_CENTS = 10 (10c per coin) so the bulk discount on the BUY side is absorbed by the platform as a marketing cost rather than dragging down creator revenue. When the season owner isn't connected to Stripe, the row writes as platform_retained so reconcileRetainedEarnings can sweep it up later — same fallback shape the dollar-tip flow uses. Route: POST /api/drift/seasons/:id/coin-tip (body { episode_number, coins, note? }). UI: CoinTipModal shows current balance + cents-equivalent + writer's expected share; if balance is short, the primary action becomes a hand-off to BuyCoinsModal so the listener can top up without losing the chapter context. The 🪙 button is gated by season.drift_enabled + signed-in listener.
  • Drift coin store — listener buys coins via Stripe Checkout (migration 163): closes the money-in side of the Drift currency loop. drift_coin_purchases table is the audit trail (mirrors episode_tips): package_key + coins + amount_cents snapshot at purchase time, stripe_checkout_session_id (unique), status lifecycle (pendingpaid / failed / refunded), and related_transaction_id FK to drift_transactions so a support refund flow can follow purchase → wallet credit → mutate-back. Package tiers live in code (drift-coin-purchases.ts — Starter $5/50, Standard $15/200, Generous $30/500; per-coin price drops with bundle size as a soft bulk-discount lever) so they can be flexed without a migration. createCoinPurchaseCheckout pre-inserts a pending row + creates a Stripe Checkout session with metadata.kind='drift_coin_purchase'; recordCoinPurchase (called from the existing webhook in payment.ts) is idempotent and credits the wallet via the same mutateBalance path treasure claims use, with reason='coin_purchase' — keeps the wallet ledger as the single source of truth. Routes (mounted with the rest of /api/drift): GET /coins/packages (public), POST /coins/checkout (authenticated). Frontend: BuyCoinsModal (components/seasons/BuyCoinsModal.tsx) + new fetchCoinPackages / startCoinCheckout helpers in lib/drift.ts; the existing 🪙 wallet pill in the read-page header is now a clickable button that opens it. After Stripe redirects back with ?coin_purchase=success, the pill refreshes the wallet on a 1.5s timer (covers the webhook-credit window) and strips the query so a later navigation doesn't re-fire. Server-side analytics: new drift_coins_purchased PostHog event closes the funnel from "open the modal" to "coins delivered."
  • Drift — segment-level decision points (migration 159): drift_decision_points table holds per-take pause-and-pick moments — paragraph_idx (the prose line listener UX pauses BEFORE), prompt (one-line setup), choices jsonb array (2–3 entries with label / hint / is_default / target_take_id), countdown_seconds (per-point timer override), intensity_at_point (denormalized from chapter_stakes for filtering + analytics), and a curation lifecycle (statusproposed / live / archived). Listeners only see live rows; admins curate everything. Per-show drift_pressure_mode flag on story_seasons decides whether decision points without explicit countdowns inherit a 30s timer + auto-default-on-expiry (thrillers ON, cozy genres OFF). Service: drift-decision-points.tsproposeForTake(takeId) runs gpt-4o-mini over the take's prose + spine + stakes, returning 1-3 high-tension beats with divergent choices (the prompt instructs the LLM to avoid moments where the prose has already committed to an action). createManual(), setStatus(), listForTake/Season() round out the surface. Routes mounted at /api/drift: POST /takes/:takeId/decision-points/propose, POST /decision-points, PATCH /decision-points/:id, DELETE /decision-points/:id, GET /takes/:takeId/decision-points, GET /seasons/:id/decision-points (live-only for non-admins). UI: 🎲 Decisions button in SuperAdminPanel (drift_enabled-gated) opens DriftDecisionPointsModal — per-chapter sections with a Propose button + cards listing each decision point's prompt, choice cards (default highlighted amber), intensity, countdown, and Promote/Archive controls. ⏱ Pressure-mode toggle next to 🪙 Drift enabled in the SuperAdminPanel config row. Listener traversal UX (audio pause + countdown ring + choice cards) is the next slice.
  • Drift v0 — branching narrative + in-app currency (foundations) — listener-driven layer on top of journal-mode shows. Migration 158_drift.sql lays the schema for ALL three coupled mechanics: (1) branches as story_episode_takes with new parent_take_id + branch_label + is_drift_branch columns (forking UX ships in slice 2; takes already had version history so we don't need a new tree table), (2) wallets in drift_wallets with balance + lifetime_earned + lifetime_spent, and (3) treasures in drift_treasures (admin-planted hidden marks listeners find on text selection) + drift_treasure_claims (audit + double-claim prevention). Per-season story_seasons.drift_enabled flag opts a show in. Service: drift.ts — wallet ops via mutateBalance (atomic credit/debit + transaction log), plantTreasure, listTreasures (anchor text gated to admins; non-admins get hint + reward + claimed_by_me only), claimTreasure (normalized anchor matching: case + whitespace tolerant, accepts substring either direction), discoverTreasureByText (one-shot find-and-claim by selection — drives the listener-side flow). Routes mounted at /api/drift: GET /wallet, GET /wallet/transactions, GET /seasons/:id/treasures, POST /seasons/:id/treasures, PATCH /treasures/:id, POST /seasons/:id/discover, POST /treasures/:id/claim. Frontend: small drift.ts hook (useDriftWallet + discoverTreasure + plantTreasure), 🪙 wallet pill in the read-page header (drift_enabled-gated), DriftDiscoveryListener that listens for text selections + POSTs to /discover for non-admin signed-in listeners, and a new "🪙 Plant Drift treasure" action in ChapterSelectionMenu for super-admins (note format: reward | description). Slice 2 wires fork creation + the spend path.
  • Admin/creator filter bar on /graphene (GrapheneClient.tsx): super-admins and creators get four dropdowns above the slate — Visibility (All / Published / Hidden), Version (All / Current / Behind / No chapters), Universe (All / each universe present / Standalone), Genre (All / each genre present, derived from data). When any filter is non-default, the page swaps to a single flat poster grid that pulls from BOTH the published seasons and the admin-only hiddenSeasons pool — universe grouping and the dedicated Hidden tail section are bypassed because the filtered result is one obvious list. Reset chip on the right shows the live match count and clears all four to default. Public visitors see the existing universe-grouped layout unchanged.
  • Graphene-branded header on cinematic routes (GrapheneHeaderBrand): on /graphene + /seasons/* (already gated by slimNav in Navbar.tsx), the HiveJournal logo is swapped for a Graphene wordmark + hex-lattice mark. Click opens a dropdown with Graphene-network links (All shows → /graphene · Universes → /dashboard/universes · Graphene+ → /graphene-plus · Podcast RSS → /api/podcast/rss) plus a HiveJournal logo + label that takes the user back to the main journal app at /. Closes on outside click + Esc. Lets the audio-fiction surface read as its own thing while keeping a one-click escape back to the journal product.
  • Audio-vs-prose staleness detection — when a chapter's prose gets regenerated (manual "Regenerate prose," bulk improve without audio re-render, etc.) the audio file still says the OLDER prose. Karaoke alignment fails (highlight tracks current prose; audio reads earlier prose), and casual listeners hear text that doesn't appear on the page. Backend now computes audio_stale: boolean per chapter on both GET /api/story-seasons/:id and GET /api/story-seasons/:id/chapters by comparing chapter_audio_rendered_at against the live take's promoted_at || created_at. Public boolean; the timestamps that drove it stay admin-only. Frontend: read page renders an amber banner under the chapter's audio player when stale ("This chapter's audio is from an earlier version of the text…") + skips karaoke highlighting for that chapter (avoids leading the listener to wrong lines). Audio playlist info box (and read-page admin info box) shows a stale · re-render chip next to the Audio row so admins spot drift before listeners do.
  • "Read from here" deep-link from the audio player to the read view (ChapterPlaylistPlayer.tsxReadClient.tsx): a 📖 pill in the now-playing strip on /seasons/[id] opens /seasons/[id]/read?at={currentTime}&karaoke=1#chapter-{N} in the same tab. ReadClient resolves the URL once chapters load: subtracts intro_seconds, walks paragraph_durations to find the paragraph matching that audio time, then scrolls the matching [data-chapter-paragraph] into view. ?karaoke=1 is a one-shot override that turns karaoke on for the visit without persisting to localStorage (the user's preference shouldn't change just because they used "Read from here"). After scroll, the at param is stripped from the URL so a refresh doesn't fight the user's later scrolling.
  • Karaoke mode on the read page (ReadClient.tsx KaraokeProse): opt-in toggle in the page header (🎤 Karaoke, persisted in localStorage as hj_read_karaoke). When on, each chapter's prose renders as sentence spans and the active sentence is highlighted (soft amber underline, no per-word jitter) + auto-scrolled into view as the chapter's audio plays. Timing model is segment-aware: the /chapters endpoint now returns intro_seconds (host narrator's "Chapter N: Title" preamble), paragraph_durations (per-journal_entry-segment audio length), and outro_seconds, sourced from season_audio_segments.duration_seconds. The frontend subtracts intro_seconds from currentTime (so the host's chapter-intro narration doesn't push the highlighter off by ~5–10s for the rest of the chapter), walks paragraph durations to find the active paragraph, then interpolates within that paragraph using sentence char-length weights to pick the active sentence. Legacy chapters without segment durations fall back to a global linear weight estimation across the whole prose. Highlight is applied imperatively via a .kp-active class to avoid React re-rendering hundreds of spans on every timeupdate; auto-scroll only fires when the active sentence leaves the middle 25–70% of the viewport so the page stays calm. Initial sync forces a one-time scroll regardless of pause state so a listener toggling karaoke mid-chapter sees the highlight immediately.
  • Per-chapter info box on listener surfaces (audio playlist + read view): both surfaces now expose a hover-only "ⓘ" pill next to the chapter eyebrow with Platform · Take · Text · Audio rows. Audio playlist: rows in ChapterPlaylistPlayer.tsx get the existing tooltip extended with the platform-version row (cyan-current / fuchsia-behind chip + model_key in monospace) so admins can spot stale chapters mid-listen. Read view: ReadClient.tsx now re-fetches /chapters with the auth header once super-admin is detected (mirrors SeasonClient.tsx's pattern); the /chapters endpoint returns the same admin-only takes metadata when an auth token is supplied. Each chapter article header gets the <ChapterAdminInfo> pill — same Platform/Take/Text/Audio block. Non-admins see neither the pill nor the metadata fields.
  • Initial registry seed: v1.0 (initial chapter-takes pipeline), v1.1 (Specifics Sharpener critic), v1.2 (Continuity Auditor + Cliché Hawk — current). Notes field is the only doc super-admins see when deciding whether to regenerate, so write it for that audience.

Cliché Hawk critic + indexed phrase library

Lexical critic that flags overused AI / commercial-prose tells ("standing on the precipice of", "felt a pang of", "let out a sigh", etc.) without calling GPT — pure regex sweep, $0 per pass. Migration 135_prose_cliche_phrases.sql adds the curated phrase table seeded with ~40 patterns; super-admins manage it at /dashboard/admin/prose-cliches.

  • New lexical_only?: boolean flag on the ScriptCritic interface (in script-critics.ts). When true, the chapter-take critic runner (episode-take-critiques.ts) bypasses the GPT call and routes through prose-cliche-detector.ts instead. Findings still land in the same episode_take_critiques table — model_key is set to 'lexical' and cost_cents to 0 so admins can tell GPT runs apart from regex sweeps in version-history views.
  • The detector caches active patterns in-process for 5 minutes (admin writes call bumpClichePatternCache() to invalidate immediately), compiles literal patterns with escapeForRegex and regex patterns with the gi flags, scans paragraph-by-paragraph, and emits one ChapterCritiqueFinding per match (capped at critic.max_findings = 12 per pass).
  • Hit counters: every match increments the row's times_flagged (best-effort, fire-and-forget). The admin UI sorts by hit count so you can retire seldom-fired patterns and prioritize new ones.
  • Backend admin routes (super-admin only, in admin.ts): GET /api/admin/prose-cliches, POST /api/admin/prose-cliches, PATCH /api/admin/prose-cliches/:id, DELETE /api/admin/prose-cliches/:id. POST + PATCH validate regex compilation up front so admins get an instant error rather than a silent skip during critique.
  • UI at /dashboard/admin/prose-cliches: add-pattern form (literal vs regex toggle, severity picker, notes field), library list sorted by hit count with severity / regex / disabled badges, in-row severity dropdown + enable/disable + delete. Linked from the /dashboard/admin tab strip as 🪶 Prose clichés.
  • Added to DEFAULT_CRITIC_KEYS so cliche_hawk runs as part of every auto-refine sweep. New patterns added via the UI start firing on the next chapter take (cache TTL is 5 min; explicit bump on writes makes it effectively immediate).
  • Seed list curated for the AI-prose tells we've actually seen ("delve into", "little did", "tapestry of", body-betrayal verbs, the stretching silence, etc.). Designed to grow — admins should expect to add a few patterns each month as they spot new ones.

Continuity Auditor critic — cross-chapter bible enforcement

Sixth entry in script-critics.ts. Specialty: cross-chapter continuity drift — character voice / behavior / timeline / setting / object-motif / plot-thread / character-introduction errors. The bible was previously a passive read-only injection; this critic makes it enforceable.

  • New needs_cross_chapter_context flag on the ScriptCritic interface. When set, episode-take-critiques.ts runChapterCritique loads the current Story Bible (token-budgeted via buildBiblePromptBlock(maxTokens: 1800)) PLUS the last 3 prior chapters' chapter_prose (each capped at 4000 chars to keep the prompt sane), and injects both into the user prompt under labeled headers. Single-chapter critics get the existing minimal context — no extra cost.
  • Graceful degradation: if context loading fails (no bible yet, no prior chapters, DB hiccup), the critic falls back to single-chapter mode rather than failing. Better degraded than zero.
  • Calibration: prompt distinguishes "drift" from "intentional development" or "a reveal the chapter is staging" — the critic only flags actual contradictions, not character growth on-page. Severity scale: minor (tonal mismatch) → moderate (specific factual contradiction) → major (bible-breaking; chapter cannot ship).
  • Added to DEFAULT_CRITIC_KEYS so the auto-refine flow runs it automatically. Chapter 1 has no prior chapters so context is bible-only; later chapters get the full cross-chapter sweep.

Creator data isolation (Phase 3 SaaS prep)

Per-creator ownership of seasons + universes so outside writers (closed-beta or paid Studio) can use the platform without their WIP bleeding onto the public Graphene slate or into other creators' admin views. Migration 134_creator_data_isolation.sql adds:

  • story_seasons.owner_user_id (nullable; NULL = platform-owned, UUID = creator-owned)
  • story_seasons.is_published_to_graphene (default false; creator-owned seasons are private until the owner explicitly publishes)
  • story_universes.owner_user_id (same model — platform universes shared as lore; creator universes private to owner)

Backfill is implicit — every existing row defaults to NULL owner, preserving "platform-owned" semantics for everything pre-Phase-3.

  • Per-:id ownership guard in story-seasons.ts via router.param('id', ...) — fires for every route capturing :id, covering all 75 per-season routes from a single registration. Anonymous reads allowed only for platform-owned OR published seasons (others 404 to avoid info leak); creator non-owner gets 404 on read of private content + 403 on mutating requests; super_admins bypass.
  • List filter: GET /api/story-seasons super_admins see every row; creators see their own + platform-owned (PostgREST OR filter on owner_user_id).
  • Public slate filter: GET /api/story-seasons/public adds or(owner_user_id.is.null,is_published_to_graphene.eq.true) so platform shows always appear and creator shows only when owner has flipped the toggle.
  • Universe list filter: listAllUniverses({ restrictToOwnerUserId }) in story-universes-db.ts filters DB rows; hardcoded universes always included since they ship with the codebase as platform-shared lore.
  • Create stamps: POST /api/story-seasons and POST /api/story-seasons/universes look up the caller's role and stamp owner_user_id = req.user.id only when caller is NOT super_admin. Super_admin creations stay platform-owned (NULL).
  • Publish toggle endpoint: PATCH /api/story-seasons/:id/published flips is_published_to_graphene. Creator-owned only (platform seasons ignore the flag via the public-slate OR clause but still accept the call for symmetry).
  • Frontend: /dashboard/admin/seasons gets a "Publish to /graphene" checkbox per creator-owned season + status badges (creator-owned, private / published) so super_admins can see WIP at a glance.

What's deferred (Phase 3.x): paid Studio onboarding (/studio landing + Stripe price tiers + usage caps), tier-based plan_keys (studio_hobby / studio_pro / studio_studio), per-tenant ElevenLabs/OpenAI usage metering. The capability flag (is_creator) is the gate; Stripe webhook would auto-set it on successful Studio checkout when ready.

Role capability flags + roles UI

Granular grants on top of the coarse profiles.role column. Migration 133_role_capabilities.sql adds two boolean flags:

  • is_creator — universe / season / take / audio / pullquote management. The "creator-side admin" tier.
  • is_business_partner — read-only access to /dashboard/admin/monetization. For non-creator stakeholders (advisors, accountants, investors).

super_admin always implies both capabilities AND grants the trust-only powers (impersonation, role changes, site settings, user management) that no flag exposes — those stay locked behind the role itself so they can't be granted granularly.

  • Helpers + middleware: database/roles.ts (canCreate, canViewMonetization, userCanCreate, userCanViewMonetization) + middleware/role-capabilities.ts (requireCreator, requireBusinessPartnerOrCreator).
  • Story-seasons + story-universes routes: the local requireSuperAdmin in routes/story-seasons.ts was widened to accept super_admin OR is_creator. Naming kept (grep parity) but semantics broader. All 86 gates in that file are creator-side admin and share the same check.
  • Monetization route: GET /api/admin/monetization/pipelines uses requireBusinessPartnerOrCreator so both capability holders see it.
  • Frontend mirror: lib/role-capabilities.ts (canCreate, canViewMonetization, isSuperAdmin). Used by /dashboard/admin/monetization's auth-gate; /dashboard/admin/seasons + /dashboard/admin/universes rely on backend 403 (no client gate).
  • Creator universe management at /dashboard/universes (creator-or-super_admin) — distinct from the super-admin-flavored /dashboard/admin/universes. Lists the caller's own universes plus platform-owned ones they can attach seasons to, with per-universe stats (season count, last activity), inline linked-seasons, and a "Move →" picker on every season row that PATCHes /api/story-seasons/:id/universe to detach or move a season between universes. Standalone seasons (no universe) get their own section with the same picker. Reuses UniverseEditorModal for create/edit. Backend: new PATCH /api/story-seasons/:id/universe (auth: per-:id ownership guard + creator can only attach to platform/own universes) and new GET /api/story-seasons/universes/mine (returns universes scoped to caller + decorated with linked seasons + standalone bucket).
  • Per-universe cover image (migration 149) — story_universes.cover_image_url (text, nullable) lets creators brand each universe with a banner image instead of relying on icon + gradient alone. v1 stores an external URL (creator-hosted; no upload widget yet). Surfaced on three displays: hero banner on /universes/[key], per-universe section banner on /graphene (via the new public GET /api/story-seasons/universes/branding endpoint, fetched once on mount), and thumbnail tile on the dashboard universe card. Each surface gracefully falls back to the gradient/icon layout when cover_image_url is null. Editable via UniverseEditorModal's new "Cover image URL" field next to the gradient input.
  • Per-universe season ordering (migration 150) — story_seasons.position (int, nullable) is the creator-set order of seasons within a universe. NULL = "no explicit position." Sort key everywhere a universe-scoped list appears: ORDER BY position ASC NULLS LAST, created_at DESC. Backend: new PATCH /api/story-seasons/universes/:key/reorder accepts { order: [seasonId1, seasonId2, …] }, sets position by array index, and clears positions for ids not in the array (atomic — one bad id rejects the whole call). Updates touch four list queries: /api/story-seasons/public, /api/story-seasons/universes/mine, /api/story-seasons/universes/:key/public. Frontend on /dashboard/universes: HTML5 drag-and-drop on every linked-season row (⋮⋮ handle on the left, drop-target ring feedback) — only on owner-editable universes; built-in/platform universes show a read-only row. Each drop fires the bulk API with the full new order array — server-side stays idempotent.
  • Bulk-move seasons on /dashboard/universes — checkbox per season row, multi-select spans every universe + the standalone bucket. When 1+ are selected a sticky bottom action bar appears with a target-universe picker + Apply + Clear. Apply fires N parallel PATCH /api/story-seasons/:id/universe calls; partial failures surface in the existing error banner. Selection clears on success.
  • Universe cover-image upload (migration 151) — universe-covers Supabase storage bucket (5MB ceiling, PNG/JPG/WebP). RLS scopes mutations to <user_id>/... so creators can only modify their own folder; public read so the cover URL works on /graphene + /universes/[key] unauthenticated. UniverseEditorModal's CoverImageField widget pairs an external-URL paste field with a file-upload button; both paths produce a URL written back into the same field, with a live preview tile below.
  • "Build from a journal" universe-bootstrap mode: a fifth bootstrap option in UniverseEditorModal ("📓 From a journal") + matching 'from_journal' mode in universe-bootstrap.ts. The user picks one of their notebooks; the service reads up to 200 entries (capped at 50k chars), feeds them as a corpus to gpt-4o, and asks the LLM to distill a full universe (label, tagline, characters, settings, motifs, themes, voice/tone rules, lore_notes, prompt_directive) that crystallizes the journal's atmosphere — leaning on source language, quoting verbatim where it lands, never sanitizing. Lower temperature (0.7 vs 0.85) keeps the output close to the writer's actual voice. On Save the universe is created with source_notebook_id already set, so the facets pipeline's "Scan journal" button is one click away — same notebook, deeper extraction. Bootstrap_input column stamps notebook:{id} so the original source is recoverable.
  • Universe ↔ source-journal pipeline (v1) — each universe can be linked to one of the user's notebooks; an LLM scan extracts atomic FACETS from the journal that the admin reviews and toggles into the chapter prompt. Migration 157_universe_journal_facets.sql adds story_universes.source_notebook_id + last_scanned_at and a new universe_journal_facets table (id, universe_id, source_entry_id, category, summary, full_text, confidence, included, pinned, snoozed, is_stale, first/last_seen_in_scan_at, model_key). Loose categories (character_seed / place / motif / voice_note / theme_seed / rule / plot_idea / atmosphere / other) — NOT enforced via CHECK so future scans can add new categories without a migration. Service: universe-journal-facets.ts scanUniverseJournal(universeId) reads up to 200 entries (capped at 60k chars), prompts gpt-4o-mini in JSON mode for atomic facets, dedupes against existing rows by normalized (category, summary), inserts new + bumps last_seen on matches + flips is_stale=true on existing-but-not-rescanned (pinned facets are immune). Routes: POST /universes/:keyOrId/scan-journal · GET /universes/:keyOrId/facets?include_snoozed=... · PATCH /universes/:keyOrId/facets/:id (toggle included/pinned/snoozed/edit) · PATCH /universes/:keyOrId/facets/batch (bulk by category) · DELETE /universes/:keyOrId/facets/:id. UI: UniverseEditorModal.tsx gets a SourceJournalSection with notebook picker + 🧠 Scan + 📋 Review-facets buttons (only on saved DB universes). The review tray groups by category, shows confidence chips, low-confidence + snoozed items hide behind toggles, and per-row Include / Pin / Snooze affordances + "Include all in category" batch button. Pin = "always include, never auto-stale." Round-trip safe: facets live alongside the structured canon JSON, not inside it — the journal stays free-form, only toggled facets feed downstream prompt building (chapter-prompt integration is a follow-up; for v1 the data + review UI ship first).
  • Universe canon GUI editorUniverseEditorModal used to edit canon as a raw JSON textarea, which scared off non-engineer creators and broke easily. Now defaults to a structured GUI: synopsis hint + repeatable cards for characters / themes / settings / motifs / planted threads + chip-list editors for voice rules / tone rules / do-not-use. The "{ } Edit as JSON" toggle in the Canon section drops back to the raw JSON textarea for power users (and for fields the GUI doesn't expose). Round-trip safety: the GUI mutates only known keys on the canon object, so character fields like secret_knowledge, relationships, physical, inner_life, backstory are preserved across GUI edits — each card surfaces a "Round-tripped: …" footnote naming what's preserved so admins know to use JSON view if they need to edit those.
  • Re-bootstrap existing universe — admins editing a DB universe that was originally generated (i.e. _generated_viadescription | random | inspired_by | from_journal) see a 🎲 Re-bootstrap from {mode} button at the top of the editor. It replays the row's saved _bootstrap_input against POST /api/story-seasons/universes/bootstrap with the same mode, then drops the new draft into the editor in place — preserving the row's identity fields (key, _generated_via, _bootstrap_input, source_notebook_id, last_scanned_at) so only LLM-generated content (label, description, prompt directive, canon, etc.) swaps out. Save still does a PATCH the admin reviews; no auto-save. For-journal mode parses notebook:<id> back out of bootstrap_input to reconstruct the request. Dirty-edit guard: a JSON snapshot of the loaded draft is taken on open and after each successful re-bootstrap; if the current draft differs from the snapshot when the button is clicked, the user gets a window.confirm before clobbering manual edits. Hardcoded universes (TURING_LOGS etc) have no _generated_via so the button never appears for them.
  • Creator entry-points from /graphene — public network landing now surfaces management links for creators/super-admins viewing it. New auth-gated GET /api/story-seasons/universes/manageable-keys returns the keys the caller can edit; GrapheneClient fetches it on auth and uses the result two ways: (1) a small "🌌 Your universes →" pill in the Shows-section header next to the filter chip (visible to anyone with the creator capability) linking to /dashboard/universes; (2) a "Manage →" emerald pill on every per-universe section header where the viewer is the owner (or super-admin). The Manage link uses /dashboard/universes#${key} deep-link — the dashboard page reads the hash, scrolls the matching #universe-${key} card into view, and pulses its border for 2s so the creator can spot it in a long list.
  • Per-season Edit affordance on /graphene postersPosterCardWithVersion now leads its chip row with an "Edit →" link to /seasons/[id] (where the existing in-page admin tools live). Only renders when the viewer has versionStatus for that season (super-admin or owner-creator), so public visitors see a clean poster. Standalone seasons swapped from the bare PosterCard to the version-aware wrapper too, so manageable standalones get the same Edit/Published/Upgrade chips as universe-attached ones (gracefully degrades to plain PosterCard when versionStatus is null).
  • Plan-anchored novella target length (migration 152) — story_seasons.target_episode_count (int, NOT NULL, ≥1) is the planned length of the novella. Until 152, the engine treated "the chapter that happens to be last right now" as the climactic chapter — appending another chapter silently demoted the previous final into a midpoint, which collapsed dramatic shape. Now: chapter prompt says "you are chapter X of N (planned)" using target as the denominator; final-chapter pacing rules trigger only when episode_number === target_episode_count (and emit explicit "this IS the final chapter, resolve the central tension" guidance — versus the non-final guidance to escalate without resolving). chapter-spine.ts and chapter-stakes.ts compute "final = chapter target_episode_count" so escalation curve is anchored to the planned destination, not current state. Backfill on migration sets target = max(current chapter count, 1) for existing seasons. extendNovelaWithChapter accepts extendTargetTo so a creator can say "extend the novella to 12 chapters" rather than letting each "add chapter" silently bump target by 1; result includes arc_extended_by_one so frontends can warn when the dramatic arc is being collapsed by a 1-step extend.
  • Editing the plan length post-creation — new PATCH /api/story-seasons/:id/target-episode-count accepts { target_episode_count: number }. Validates the new value is ≥ existing chapter count (can't drop below chapters that already exist). Side-effect: synchronously re-derives every chapter's dramatic_role (cheap, no LLM) AND best-effort regenerates chapter spine + stakes (fire-and-forget; both LLM calls encode "final = chapter target"). Frontend: EpisodesAdminModal adds a 📏 Plan: N chapters chip in the novel-mode admin chrome — click to edit inline (number input + ✓/✕ buttons), Enter to save. Min floor enforced client-side at the existing chapter count.
  • Per-chapter dramatic role (migration 153) — story_episodes.dramatic_role (TEXT, CHECK-constrained to opening | inciting_incident | rising | midpoint | crisis | climax | resolution). Each chapter carries its role explicitly so the chapter prompt + spine + stakes layers can inject beat-specific guidance instead of inferring from position. chapter-dramatic-roles.ts exports deriveDramaticRoleFromPosition (cheap heuristic), computeDramaticRolesForSeason (re-derives all chapters in a season), validateDramaticRole (controlled-vocab guard for LLM input), and buildDramaticRoleGuidance (the per-role chapter-prompt block — ~80 words of craft instruction per role, e.g., midpoint = "point of no return, central question reframes," crisis = "darkest moment, old solutions fail"). Distribution heuristic: ch 1 = opening, ch 2 = inciting, ceil(N/2) = midpoint, last = climax, post-75% = crisis, else rising. generateNovelSeasonPlan asks the LLM to assign roles per chapter; if the model omits or returns invalid values, the heuristic fills in. Plan-length changes auto-recompute roles via the target-update endpoint. Backfill in the migration assigns roles to every existing novel-mode chapter using the same heuristic.
  • Manual override of a chapter's rolePATCH /api/story-seasons/:id/episodes/:n/dramatic-role accepts { role } (controlled-vocab validated). Lets a creator override the heuristic / planner's choice (e.g., a deliberately late inciting incident, splitting climax + resolution). Surfaced as a tinted dropdown chip on each chapter row in EpisodesAdminModal — color reflects the role's dramatic position (climax/crisis = rose, midpoint = fuchsia, inciting = amber, resolution = cyan).
  • Lock-on-override (migration 154) — story_episodes.dramatic_role_locked (boolean NOT NULL DEFAULT false). The role-PATCH endpoint flips this to true, and computeDramaticRolesForSeason skips locked rows so a future target_episode_count change preserves manual overrides. New PATCH /api/story-seasons/:id/episodes/:n/dramatic-role/unlock releases the pin AND immediately re-derives the role from current position (saves the creator from having to bump target just to refresh). Frontend: locked rows show a 🔒↻ button next to the role chip — click to release the pin and rejoin auto-management. Returns { updated, skipped_locked, target } from the recompute service so admin tooling can see how many manual overrides were preserved.
  • Locked-roles count chip + bulk unpinEpisodesAdminModal now shows a 🔒 N of M pinned chip in the admin chrome alongside the narrative-mode + plan-length controls (self-hides when nothing is pinned). Click to bulk-release every manual role override on the season — confirms first since it's destructive of creator intent. Backend: new POST /api/story-seasons/:id/dramatic-roles/unpin-all clears every chapter's dramatic_role_locked flag and synchronously re-derives roles via computeDramaticRolesForSeason. Returns the same { updated, skipped_locked, target } shape so the admin tooling reads consistently.
  • Mood-impact accent on pacing cells — each cell gets a thin 3px left-edge bar tinted by mood_impact (-2..+2): emerald for positive (uplift), rose for negative (descent), height = magnitude/2. Self-hides when mood is zero. Surfaces tonal arc separately from dramatic arc — a chapter can be a "rising" beat AND a tonal uplift, or a "midpoint" AND a tonal descent. No backend change required; mood_impact was already on every episode row from the planner.
  • Planted-thread payoff tracker — collapsible panel under the pacing strip lists every planted_thread from the bible sorted by urgency (overdue → developing → planted → paid_off → dropped). Status chip + plant→payoff trajectory + summary per row. Empty state for healthy seasons reads "No overdue threads — all planted threads target chapters that aren't written yet." PacingStrip cells get a 2x2 red dot in the top-right corner when 1+ threads target that specific chapter for payoff but status is still 'planted'/'developing' (the chapter exists but didn't close what was planted for it). Bible loaded best-effort on the same load() that fetches episodes; failure leaves the panel empty.
  • POV-character chip per cellchapter_stakes JSONB gained an optional pov_character_key field. Stakes service asks GPT to identify whose pressure shapes each chapter (must match a bible character key). Frontend renders a tiny initial chip (3.5×3.5 rounded) in the bottom-right corner of each pacing-strip cell. Lets creators scan the strip and spot characters who drop out of focus across many chapters. Tooltip shows full name. Self-hides when chapter_stakes lacks the key (legacy chapters or stakes that haven't been recomputed).
  • Text-free poster regen + typographic overlay — DALL-E consistently bakes scrambled gibberish text onto generated posters even when prompted not to. Two-pronged fix: (1) The prompt in season-media.ts buildPosterPrompt is hardened with multiple negative reinforcements ("ABSOLUTELY NO TEXT… no titles, no actor names, no taglines, no rating boxes, no studio logos, no signage, no books with visible spines… If you find yourself drawing letters, replace them with abstract texture or remove them") to reduce the rate. (2) PosterCard composites a clean typographic overlay on top of every poster — the title (large, bold, drop-shadow) + setting/genre subtitle in the lower third with a black gradient backdrop — so any residual scrambled DALL-E text gets buried under intentional design. New POST /api/story-seasons/:id/regenerate-poster route + regenerateSeasonPoster() service do a poster-only regen (single DALL-E call, ~$0.08) leaving cast portraits untouched — surfaced as a 🪧 Regen poster only button in the visuals stage of StageStrip. The original 🎨 Regen visuals button still does the full poster + N portraits regen.
  • Two-mode novellas: bounded vs serialized (migration 155) — story_seasons.narrative_mode ('bounded' | 'serialized', NOT NULL DEFAULT 'bounded'). Bounded = planned ending, full dramatic-shape engine (target_episode_count enforced as climax destination, final-chapter pacing, role distribution with climax beat). Serialized = open-ended fiction with no climax beat — chapter prompt drops "X of N" + final-chapter rules, spine/stakes drop "chapter N is FINAL" framing, every chapter past the opening is 'rising' (no climax/resolution roles), extendNovelaWithChapter doesn't flag arc collapse. Backfill: novel-mode seasons → 'bounded' (preserves current behavior), journal-mode → 'serialized' (matches existing semantics). POST /api/story-seasons accepts optional narrative_mode body field; generateNovelSeasonPlan accepts narrativeMode arg. Frontend: EpisodesAdminModal shows a 📖 Bounded / ♾️ Serialized badge in the novel-mode chrome; the plan-length editor is hidden for serialized seasons (target is informational only there). Season-creation form on /dashboard/admin/seasons has a bounded/serialized toggle (Arc: row, novel-mode only — journal is inherently serialized) right under the journal/novel mode picker. Default 'bounded'. Persisted in the localStorage draft alongside the other form fields so a creator's choice survives a tab close.
  • Narrative pacing strip in EpisodesAdminModal — at-a-glance horizontal strip with one cell per chapter, colored by dramatic_role (rose = climax/crisis, fuchsia = midpoint, amber = inciting, cyan = resolution, emerald = opening, purple = rising). Lets creators spot pacing problems before reading prose ("chapters 5-7 are all crisis but no midpoint" / "no inciting incident" / "two climaxes back-to-back"). Bounded seasons render dashed-border ghost cells for chapter slots up to target_episode_count that haven't been written yet — shows the planned arc, not just what exists. Serialized seasons render only existing chapters. Locked cells (manual role overrides) show a 🔒 dot. Clicking a cell smooth-scrolls to that chapter row in the list below (data-chapter-row id used as the scroll target).
  • Stakes-intensity overlay on pacing cells — each pacing-strip cell now has a thin progress bar at the bottom whose width = stakes intensity (1-10). chapter_stakes JSONB on story_episodes gained an optional intensity field that the chapter-stakes service asks GPT to fill in alongside the qualitative wants/obstacle/cost; computeChapterStakesForSeason clamps invalid values + drops them so the heuristic kicks in. Frontend bar is solid-white-85% when the score is from a real stakes compute, faint-white-40% when it's the role-derived heuristic (opening=3, inciting/rising=5, midpoint=7, crisis=8, climax=9, resolution=6) — gives creators a non-flat curve even on legacy chapters that haven't been re-stakes'd, and signals "recompute stakes to see real numbers." Tooltip on each cell distinguishes the two sources.
  • Narrative-shape header on /seasons/[id]/read — the existing thin purple progress bar is now a two-layer narrative-shape header that visualizes the season's dramatic arc as the reader scrolls. (1) An SVG area chart sketches per-chapter stakes intensity (1-10) as a smooth filled curve — readers see "we're climbing toward something" or "we're past the peak" without spoilers. Falls back to the same role-derived heuristic the pacing strip uses when chapter_stakes lacks intensity. (2) A gradient progress bar where the background spans every chapter's role-keyed color (rose for climax/crisis, fuchsia for midpoint, etc.); width clips to scrollProgress, so as the reader moves through the season the bar's rightmost color shifts (purple → fuchsia → rose) to match where they are in the arc. Same hex palette as the pacing strip + chapter row chips so the visual language is consistent across creator + reader surfaces. Backend: GET /api/story-seasons/:id/chapters now includes dramatic_role, chapter_stakes, and mood_impact per chapter to power the visualization.
  • Roles & rights UI at /dashboard/admin/roles (super-admin only): lists every elevated user (anyone with role !== 'user' OR a capability flag set) with toggle chips for is_creator + is_business_partner and a "Make super_admin" / "Demote to user" action. Bottom panel grants by email for users not yet in the elevated list. Self-protection: super-admin can't demote themselves (locking everyone out). Backend: GET /api/admin/role-grants (list elevated), PUT /api/admin/role-grants/:id (update flags / role for an existing user), POST /api/admin/role-grants/by-email (first-time grant — paginates listUsers to find by email, upserts profile). super_admin promotion intentionally not exposed in the by-email panel — must use the explicit "Make super_admin" button after the user appears in the elevated list (visible audit trail). Linked from /dashboard/admin tab strip as 🛡 Roles & rights.

Story quality signals (v1.10–v2.0 — Phases 1 + 2 + 3 + 4 + 5 + 6)

TL;DR: The writer engine layers four structural-only quality signals on every chapter / season plan. None of them ingest copyrighted prose. The architecture lives in plans/quality-signals.md; this section is the feature index.

SignalSourceWhere it landsVersion
Craft beatsTypeScript constants (story-craft-principles.ts)Per-chapter beat targets in 3 promptsv1.10
Internal engagementMaterialized view of platform reads/follows/tipsPer-genre top-quartile patterns in plannerv1.11
Gutenberg corpusStats from ~50 public-domain works (no prose stored)Per-position chapter-length norms in stakesv1.12
Bestseller marketCurated facts from ~80 modern bestsellersPer-genre chapter-count defaults + example titlesv1.13
Integration polishPrecedence note + concrete examples + intensity curvesPlumbing across all three prompt sitesv1.14
Per-season framework selectionstory_seasons.craft_framework column + planner pickOnly chosen framework's beats injected per chapterv2.0

Phase 1 (v1.10) — craft beats. A "rising" chapter at position 0.3 reads as "Fun and Games" (escalating attempts, energy up) while the same role at 0.65 reads as "Bad Guys Close In" (pressure inverts). Implementation lives in story-craft-principles.ts:

  • SAVE_THE_CAT_BEATS — 15 beats (Brody/Snyder) with target position fractions + our-own-prose craft summaries. No quotation from the source book.
  • MCKEE_MOVEMENTS — five movement ranges (McKee's substance categories): Inciting Incident, Progressive Complications, Crisis, Climax, Resolution.
  • HEROS_JOURNEY_STAGES — Vogler's simplified eight-stage adaptation of Campbell.
  • getCraftBeatsForChapter(chapterNumber, totalChapters) — returns the nearest beat in each framework for a chapter at position (chapter - 0.5) / totalChapters.
  • buildCraftBeatGuidance(...) — full prompt block for chapter-level prompts.
  • buildCraftBeatTagLine(...) — one-line tag for chapter-by-chapter plan summaries.

Wired into three places:

  • chapter-dramatic-roles.ts:buildDramaticRoleGuidance appends a CRAFT BEAT TARGETS block to every per-chapter role guidance.
  • chapter-stakes.ts planSection tags each chapter line with Craft beats: SCT=X · McKee=Y · Hero=Z so the LLM computing stakes sees the full sequence.
  • plan-persona-season.ts injects the per-chapter beat sequence into the season-planning prompt so chapter titles + spines respect the dramatic arc.

Legal note: every string in story-craft-principles.ts is our own prose summarizing the public principle. Beat NAMES are common-vocabulary industry terms; no copyrighted quotations from Brody / Snyder / McKee / Campbell.

Phase 2 (v1.11) — internal engagement signal. The writer engine now learns from what HiveJournal's own readers finish, follow, and tip. New materialized view season_engagement_quartiles scores every published novel-mode season by reads_30d × 1 + follows_30d × 5 + tip_count_30d × 10 and NTILE(4)-buckets per genre. Service internal-quality-signals.ts exposes:

  • getQuartileForSeason(seasonId) → 1–4 (4 = top)
  • getTopQuartilePatterns(genre){ chapterCountP50, chapterWordsP50, titleAvgWordCount, intensityCurve[9], dramaticRoleDistribution } — aggregated from top-quartile chapters in that genre. Returns null when sample is below 8 seasons (too noisy).
  • buildTopQuartileGuidance(genre) → soft-guidance prompt block.
  • refreshEngagementQuartiles() → triggers REFRESH MATERIALIZED VIEW CONCURRENTLY.

plan-persona-season.ts injects a compact per-genre patterns table into the planner prompt so a thriller-genre plan defaults to the empirical chapter count + title length + intensity-curve shape that's worked on this platform. Skipped per-genre when the sample is too small — planner falls back to its v1.10 defaults.

Admin surface: /dashboard/admin/quality-signals (super-admin) shows the per-genre table + manual ↻ Refresh now button. Weekly refresh cron engagement_quartiles_refresh lives in index.ts (boot offset 480s); heartbeat-tracked via system-health.ts.

Phase 3 (v1.12) — public-domain corpus structural norms. New tables literary_corpus_works + literary_corpus_chapter_stats (same migration) hold per-chapter STRUCTURAL STATS ONLY from a curated list of ~50 Project Gutenberg works (Austen, Dickens, Twain, Wharton, Conrad, Doyle, Wells, Stoker, etc., all pre-1929). No prose is persisted — the ingest script reads the chapter, computes word count / sentence count / avg sentence length / dialogue density / opening + closing line word counts, writes the numbers, discards the prose. Service literary-corpus-stats.ts:

  • getCorpusNormForChapter(chapterNumber, totalChapters){ wordCountP25/P50/P75, sentenceLenP50, dialogueDensityP50, sampleSize } for the chapter's position decile. Cached for 1h.
  • buildCorpusNormGuidance(...) — prompt-ready text block.
  • getCorpusSummary() — admin overview (works + chapters + genre/era breakdowns + latest ingest).

Ingest: scripts/ingest-gutenberg-corpus.ts. Idempotent (upsert by gutenberg_id). Flags: --limit N, --ids 1342,11, --dry-run. Downloads via gutenberg.org/files/<id>/<id>-0.txt with two fallback URL shapes, strips the Gutenberg header/footer, splits chapters via permissive CHAPTER N / Chapter I / IV. heading regex with Roman-numeral support.

Wired into chapter-stakes.ts planSection — each chapter line now carries a Corpus norm at this position: P25–P75 w (P50 N, n=samplesize) tag, so the LLM sees the empirical baseline when it computes intensity. Critic chain can flag chapters that fall significantly outside (a 1200-word chapter at position 0.5 where P25 is 3000 is suspicious).

Admin surface: same /dashboard/admin/quality-signals page gains a corpus-summary block — counts + per-genre + per-era chips + latest ingest timestamp. Best-effort: empty corpus = chapter-stakes prompt skips the norm block (planner falls back to v1.11 behavior).

Phase 4 (v1.13) — modern bestseller structural norms. New table bestseller_signals holds curated entries for ~80 modern (2005–2023) bestsellers across literary / thriller / romance / mystery / fantasy / sci-fi / YA / historical / horror. Each row stores structural facts only: title, author, year, genre, chapter_count, page_count, series_position. Public-knowledge metadata scrapable from publisher listings + Wikipedia infoboxes — never the books' prose.

Service bestseller-signals.ts:

  • getGenreNorms(genre){ chapterCountP25/P50/P75, pageCountP50, seriesPositionP50, sampleSize }. Returns null when seed is < 5 entries for the genre.
  • getAllGenreNorms() — bulk fetch sorted by sample size.
  • recommendChapterCountForGenre(genre) → integer; planner-friendly default.
  • buildBestsellerGuidance(genre) → prompt-ready text block.

Seed: scripts/seed-bestseller-signals.ts — idempotent on (title, author, series_position). Run with --dry-run to preview the breakdown without writing.

plan-persona-season.ts now injects a compact per-genre table into the planner prompt covering all seeded genres: literary: chapters 25–60 (P50 30), ~400p [n=12]. Used as the cold-start default when the platform's own top-quartile data is sparse — together with Phase 2, the planner sees both "what works on HiveJournal in this genre" (when we have enough data) AND "what the broader market does in this genre" (always available once seeded).

Admin surface: same /dashboard/admin/quality-signals page gains a bestseller-norms table — sample size + chapter-count P25/P50/P75 + page-count median + series-position median per genre. New endpoint GET /api/admin/bestseller-signals.

Phase 5 (v1.14) — integration polish. Three additions tighten how the four signal sources stack onto a single generation:

  1. Signal-precedence note in the planner prompt explicitly tells the LLM what to do when blocks conflict: voice + premise authenticity always wins; platform top-quartile beats bestseller market norms; craft beats are tonal targets not numeric targets.
  2. Concrete title-shape examples per genre in the planner prompt — pulls 3 recent bestseller titles per genre from the Phase 4 seed via getExampleTitlesPerGenre() and presents them as grounding examples (NOT to copy). Concrete examples beat abstract instructions for title bias.
  3. Top-quartile intensity curve injected into chapter-stakes.ts when the season has a genre with enough top-quartile data — gives the LLM's intensity assignments an empirical target shape per-decile (0.1→0.9) instead of gut feel.

No new tables, no migration — pure prompt-engineering layer on top of v1.13. Reduces the "engine has all the signals but doesn't know which to follow" failure mode.

Phase 6 (v2.0) — per-season craft framework selection. From v1.10 through v1.14 the writer engine broadcast all three craft frameworks (Save the Cat, McKee, Hero's Journey) at every chapter in parallel. Diluted signal: a literary novella was getting told to inject "Fun and Games" energy into prose that should be tightening pressure. v2.0 moves the framework choice upstream to design time — one framework per season, picked by the planner based on premise / genre / mode, with admin override + on-demand re-recommendation.

  • Migration 262_season_craft_framework.sql adds story_seasons.craft_framework (auto | save_the_cat | mckee | hero_journey | all) + craft_framework_rationale. CHECK constraint + partial index. 'auto' is the default and falls through to 'all' at runtime — seasons in flight when the migration shipped keep behaving exactly as pre-v2.0 until something acts on them.
  • Catalog auto-upgrade: when improveAllBehindChapters runs against a season still on 'auto' (i.e., the existing catalog hits the regenerate-to-v2.0 banner for the first time), it calls recommendCraftFramework() FIRST and persists a real choice before regenerating prose. Otherwise the regenerated chapters would stamp v2.0 but still get the legacy "all three frameworks" prompt — defeating the version bump. Auto-pick failure is non-fatal: log it, fall through, regenerate with legacy treatment, admin can override via dropdown.
  • Rollout observability: /dashboard/admin/quality-signals gained a "Craft framework rollout (v2.0)" panel — counts seasons total / picked / still on 'auto', framework distribution chips, per-genre table. Powered by GET /api/admin/craft-framework-distribution (admin.ts). Excludes archived seasons. Also includes a 🎯 Pick frameworks for next N button that processes up to 50 'auto' seasons in one batch via POST /api/admin/bulk-pick-craft-framework (~$0.0005/season, ~1s each, sequential, re-clickable for catalogs >50). Picks frameworks WITHOUT regenerating chapter prose — admin can then use the per-season ↻ Regenerate banner to actually apply the new framework.
  • Self-service v2.0 verification: 📋 button on each novel-mode season row in /dashboard/admin/seasons opens a modal showing the actual prompt strings the writer engine would emit for chapter 1 under that season's current framework (craft_beat_tag_line, craft_beat_guidance, full dramatic_role_guidance), plus the beats at that position in ALL three frameworks for comparison. Powered by GET /api/admin/craft-framework-preview?season_id=X&episode_number=Y (admin.ts). Lets admin confirm "yes, this McKee season's ch5 emits ONLY the McKee Crisis movement, not all three frameworks."
  • Planner pick happens in plan-persona-season.ts — the season-scaffold prompt now asks GPT to pick one of the three frameworks + return a one-sentence rationale. Parse falls back to 'mckee' when the response is missing/invalid (safest default — shapes pressure, not beats). Persisted by all three scaffold paths: routes/ai-personas.ts (manual scaffold endpoint), writer-fleet-cron.ts, content-manager-executor.ts.
  • All four wire-in points read the column and inject only the selected framework's beats: story-craft-principles.ts (buildCraftBeatGuidance(ch, total, framework) + buildCraftBeatTagLine(ch, total, framework) — both default to 'all' for back-compat), chapter-dramatic-roles.ts (buildDramaticRoleGuidance forwards it), season-novel-chapter.ts (SELECTs include craft_framework and threads through callChapterPromptGPT), chapter-stakes.ts (SELECT + maybeSingle generic include it).
  • Admin override: 📐 Craft: dropdown in /dashboard/admin/seasons (novel-mode rows only). Routes: PATCH /api/story-seasons/:id/craft-framework (manual override — stamps rationale with "Manual override by admin (was: <previous>)"); POST /api/story-seasons/:id/recommend-craft-framework (fresh gpt-4o-mini pick against the current premise — useful when the auto-pick feels wrong or the premise has been edited since scaffold; ~$0.0005 per call). Service: recommend-craft-framework.ts.
  • Reader-side super-admin banner: /seasons/[id]/read shows a fuchsia "↻ Regenerate to v2.0" CTA at the top when one or more chapters are stamped at an older platform version. Wraps the existing /improve-all-behind endpoint; ⇧Shift skips audio re-render. Mirrors the heavier ImproveAllBehindBanner on /seasons/[id]/ overview.
  • Platform version: v2.0 entry — first major-version bump. Frontend CURRENT_PLATFORM_VERSION constant must stay in sync (was the stale-constant bug that shipped v1.15→v2.0).

v2.1 — Claude Sonnet writer engine + one-click batch regeneration

  • Migration 273 adds v2_1_regeneration_runs (one row per "Start" click — aggregate counters + status) and v2_1_regeneration_items (one row per chapter — take_id on success, message on failure, skip reason on skip). Super-admin RLS on both.
  • Backend orchestration: services/v2-1-regeneration.tsenqueueV2_1Run() resolves the candidate seasons (is_published_to_graphene=true AND mode='novel'), filters to chapters with prose, inserts the items, then fires off processRunInBackground() and returns the run row. Worker processes one item at a time (parallel would spike Anthropic concurrent requests by ~6× from auto-refine critic fan-out and trip rate limits). Skips chapters whose live take is already v2.1. Heartbeat-tracked under v2_1_regeneration_worker.
  • Routes: routes/admin-v2-1-regeneration.ts mounted at /api/admin/v2-1POST /start, GET /runs, GET /runs/:id, POST /runs/:id/cancel. All super-admin gated.
  • Admin page: /dashboard/admin/v2-1-regeneration — big start button (one-click), live progress panel (succeeded / failed / skipped / queued counters + currently-running breadcrumb + last_error), expandable per-chapter items table, and a recent-runs list with status badges. Polls /runs/:id every 4s while running, stops polling on terminal state.
  • New takes auto-stamp platform_version='v2.1' via the existing getCurrentVersion() read in generateChapterTake. Live takes stay live; admin promotes the new drafts per chapter via the existing takes UI — same lifecycle as any individual take-regenerate, just batched. Cost: ~3× per-chapter generation vs gpt-4o-mini, paid once per chapter regardless of audio re-renders.
  • Same gotcha as v2.0: frontend CURRENT_PLATFORM_VERSION constant must stay in sync — bumped to 'v2.1' in the same PR.

Legal note remains: every framework summary in story-craft-principles.ts is our own prose, no copyrighted quotations from Brody / Snyder / McKee / Campbell.

Runbook — keeping the signals fresh

One-time setup per environment:

  1. Apply migrations 251, 252, 253 via the Supabase SQL editor.
  2. Run npx tsx scripts/ingest-gutenberg-corpus.ts (Phase 3 data — downloads ~50 works, takes 2–5 minutes; supports --limit N, --ids 1342,11, --dry-run).
  3. Run npx tsx scripts/seed-bestseller-signals.ts (Phase 4 data — instant, ~80 rows; supports --dry-run).
  4. Visit /dashboard/admin/quality-signals and click ↻ Refresh now (Phase 2 materialized view).

Ongoing:

  • Phase 2 auto-refreshes weekly via the engagement_quartiles_refresh cron (heartbeat-tracked).
  • Phase 3 is static unless we add works — re-run with --ids to add new ones; idempotent.
  • Phase 4 is static unless we extend the curated CSV inside the seed script.

What this explicitly does NOT do: no model fine-tuning, no prose ingestion, no RAG over copyrighted text, no web scraping of book content. The legal stance — "structural facts only, plus our own prose summarizing public craft principles" — is documented in plans/quality-signals.md § What we explicitly chose not to do.

Books in Stream — cafe shelf injected as opt-in cards

The existing cafe_books shelf (write.cafe carousel) doubles as Stream content for users who opt in. Parallels Signal Union — same shape (per-user opt-in, reactions, every-Nth-entry injection).

Migration: 259_cafe_books_stream.sql adds profiles.cafe_books_stream_enabled + cafe_book_reactions (user/book/reaction unique, supports saved/skipped/clicked).

Endpoints added to the existing routes/cafe-books.ts:

  • Public: GET /api/cafe/books/stream-batch?limit=N — small batch of active books for stream-card injection (featured first, then sort_order)
  • Authed user: GET /api/cafe/books/me, PATCH /api/cafe/books/me, POST /api/cafe/books/reactions, DELETE /api/cafe/books/reactions/:bookId/:reaction

Stream card CafeBookStreamCard.tsx — poster-tile card with cover image (reuses bookCoverUrl from cafe-book-library — Open Library cover lookup by ISBN, or cover_image_url override). Includes 🔖 save button + click-tracked "Buy on Amazon ↗" / "Open →" link.

Stream integration — injected after every 24th entry (offset 15) so it never collides with Graphene (every 12) or Signal Union (every 18, offset 9). Gated by cafe_books_stream_enabled profile flag + StreamFilterPanel's "📚 Hide books" toggle.

SettingsCafeBooksPreferencesSection adds an amber-toned switch card next to Signal Union. Off by default; opt-in same as the rest of the cinematic-injection surfaces.

Writers Conferences directory (public SEO page + refresh cron)

A curated, public calendar/directory of writers + publishing conferences at /write-cafe/conferences — high-intent SEO surface ("writers conferences 2026") that doubles as EmberKiln's internal booth radar. Brief that seeded it: docs/product/EMBERKILN_CONFERENCES.md.

Migration: 418_conferences.sqlconferences table (slug, name, dates, location, date_confidence ∈ {confirmed, estimated, tba}, audiences[], topics[], cost_note, internal boothable/booth_note, status ∈ {published, draft, pending_review, archived}, source, last_verified_at). RLS-enabled, no anon policy — public reads go through the backend service-role route. Seeded with the deep-research-verified events + curated recurring writers conferences.

Service: services/conferences.tslistPublicConferences({ audience }) (buckets into upcoming/tba/past; never serializes internal booth notes) + refreshConferences() (deterministic maintenance: archives past events, flags published rows stale >90d). Discovery of NEW conferences is intentionally NOT auto-published — the pending_review status exists for a future human-gated LLM discovery pass.

Public API: routes/conferences.tsGET /api/conferences?audience=writers (published only, CDN-cached). The public writers page passes audience=writers so pure film/AI-tech booth events (IBC, NAB, TC Disrupt…) stay in the table for the booth radar but off the writers page.

Cron: conferences_refresh (daily) in index.ts — heartbeat (recordHeartbeat), toggle registry entry in cron-toggles.ts, and a CRON_LABELS entry in system-health.ts. Pure DB bookkeeping, near-zero cost.

Frontend: server component page.tsx (metadata + Event JSON-LD for upcoming dated events + ISR revalidate 1h) → client ConferencesClient.tsx (topic filter chips, upcoming/recurring/past sections, confidence-aware date formatting, "verify on official site" links). Linked from the write.cafe landing (📅 Conferences).

Also: added WebApplication + breadcrumb JSON-LD to random-prompt and CollectionPage + breadcrumb JSON-LD to bento-archive for "writing prompt" searches (first structured-data usage in the app).

The studio brand (audiobook creator + Scene Studio + Reverse Screenplay Engine) gets a public landing at /emberkiln. Brand context: memory project_graphene_studio_brand; name still pending clearance (EMBERKILN_NAME_CLEARANCE.md). The PILLARS grid + hero copy enumerate the forms (reader · audiobook · scenes · screenplay · songs · paperback). Hero loops: EmberkilnHeroLoop renders brief looping square clips beneath the mark, content-driven by public/emberkiln/loops/manifest.json (drop square mp4s + list them; empty manifest → nothing renders).

  • Landing: self-contained teal page (uses the emberkiln-* Tailwind palette) — hero ("One story. Every form."), the emberkiln concept (one element, many forms), three product pillars (Audiobooks / Scenes & short films / Screenplays), CTAs to /studio + graphene.fm. Server component with metadata + OG.
  • Mark: shared EmberkilnMark.tsx — the flame/ember glyph (5 strokes), currentColor. Vector source of record: apps/frontend/public/emberkiln-mark.svg. Reused in the dropdowns + landing + OG cards + print cover.
  • OG: /api/og/emberkiln — deep-teal @vercel/og card (mark + wordmark + tagline + "a graphene.fm studio").
  • Brand dropdowns: surfaced as a sibling brand (relative /emberkiln Link — no dedicated domain yet, serves on whichever brand host) in both WriteCafeHeaderBrand.tsx and GrapheneHeaderBrand.tsx.
  • Routing: served via middleware pass-through on any brand host today; when emberkiln.fm/.app is live, add a host block in middleware.ts that rewrites root → /emberkiln (mirror the lovio.io block).
  • Landing sections: hero → concept → an animated input→engine→output flow (EmberkilnFlow.tsx — a story in, every form out, the mark as the machine; CSS-only, prefers-reduced-motion aware) → 5 product pillars (reader / audiobook / scenes / screenplay / paperback) → a "see it in action" band → CTAs.
  • Discoverability: in the sitemap (priority 0.8), the Features catalog (under the "Create" life-loop chapter + a marquee "Start here" card), and both brand dropdowns.
  • Sellable-artifacts export (the API under "HJ Author Sites" — contract: crosstalk/contracts/sellable-artifacts-export.md, AGREED w/ QuickSites): owner-authed GET /api/emberkiln/works/:id/artifacts (work id = story_seasons.id) → { work, artifacts[] } with STABLE artifact_id = art_<seasonId>_<type> (QS upsert key), Lulu spec (interior/cover/pageCount/podPackageId/base_cost_cents best-effort from print_orders.cost) + digital audiobook. Slow renders via POST …/artifacts/prepare (fire-and-forget, 202; GET shows pending: true meanwhile): print assets → deterministic print/<id>/export-{interior,cover}.pdf + export-manifest.json (pageCount) via generateSeasonPrintAssets; whole-book audiobook → chapter MP3s stitched to audio/<id>/book-full.mp3 (journal mode uses season_publishings.audio_url as-is). Pure core split into emberkiln-artifacts-core.ts (golden-tested — the impure half's print/audio import chain can't load under ts-jest; suggestedPaperbackPrice MIRRORS print-storefront quoteRetail, keep in sync). Impure: emberkiln-artifacts.ts; routes in routes/emberkiln.ts. No migration (provenance table deferred per IR design §6). v1 scope: paperback+audiobook; merch export is a fast follow (Gelato rails shipped).
  • "Made with EmberKiln" gallery: services/emberkiln-showcase.ts getEmberkilnShowcase() returns public novels + the forms they've taken (detected from story_episodes.chapter_audio_url / scene_projects / screenplay_projects; reader + paperback always). Public route GET /api/emberkiln/showcase (CDN-cached, mounted /api/emberkiln). The landing server-fetches it (ISR 1h) → EmberkilnShowcase.tsx → per-work ShowcaseCard.tsx with per-form deep links (Read→/read · Listen→show · Print→/read?order=print, which auto-opens the PaperbackStorefront). Graceful fallback to a graphene.fm CTA when empty. The flow's output chips also link out.
  • Funnel analytics (GA via trackEvent + PostHog server events): client events emberkiln_cta_open_studio / emberkiln_showcase_open / emberkiln_showcase_form_click / emberkiln_flow_output_click / emberkiln_storefront_open|quote|checkout (via TrackedLink.tsx); server events emberkiln_print_checkout_started + emberkiln_print_order_paid (analytics-server.ts ServerEvent union, captured in print-storefront.ts) — the landing→studio→order funnel.

FB Reels — curated Facebook Reels in the Stream

Same pattern as Signal Union but for Facebook Reels — playback rides Facebook's official plugins/video.php iframe so the video stays inside Meta's surface (TOS-clean, no rehost).

Schema (migration 261):

  • fb_reels_creators — slug, display_name, short_bio, photo_url, fb_handle, fb_url, links jsonb, status, ai_proposed_at, curator_notes
  • fb_reels — creator_id, title, fb_url (canonical), fb_video_id, duration_seconds, mood_tags[], themes[], content_warnings[], is_featured, status, ai_proposed_at
  • fb_reel_reactions — user_id, reel_id, reaction ('saved'|'skipped'|'played'), unique on (user, reel, reaction)
  • profiles.fb_reels_stream_enabled — per-user opt-in (default false)

Service fb-reels.ts + routes routes/fb-reels.ts mounted at /api/fb-reels. Mirrors Signal Union's surface (public list/by-creator/stream-reels, authed /me + /reactions, admin CRUD).

Stream card FbReelStreamCard.tsx — builds the embed iframe by URL-encoding the canonical FB URL into https://www.facebook.com/plugins/video.php?href=<encoded>&show_text=false&width=320&t=0. Host-allowlist (only www.facebook.com / facebook.com / m.facebook.com / fb.watch URLs pass), 9:16 aspect-ratio frame, 🔖 save button.

Stream integration — injected every 30 entries (offset 21) so it never collides with Graphene (12), Signal Union (18/+9), or Cafe Books (24/+15). Gated by the profile opt-in + StreamFilterPanel's "🎞️ Hide reels" toggle. Also picked up by the "Show only: 🎞️ Reels" solo-surface chip.

Admin /dashboard/admin/fb-reels — collapsible creator list with FB-URL paste-in for reels, mood-tag chips, status flip (active/draft/retired), featured toggle.

Settings (dashboard/settings) — FbReelsPreferencesSection adds a blue-toned switch card.

Signal Union — curated music in the Stream

Curated musical artists + their songs surface in opted-in users' Streams as embedded Spotify / Bandcamp / SoundCloud / YouTube player cards. Phase 1 ships manual curation; Phase 2 will add the "Signal Union Curator" platform role seat (AI proposer) + mood-matched selector backed by the user's last two weeks of journal mood data.

Schema (migration 258):

  • signal_union_artists — slug, display_name, short_bio, photo_url, palette_hex, location_region, genres[], links jsonb (spotify_artist_url, bandcamp_url, soundcloud_url, instagram_url, website_url), status, ai_proposed_at, ai_proposed_by_model, curator_notes
  • signal_union_songs — artist_id, title, embed_url, embed_provider ('spotify'|'bandcamp'|'soundcloud'|'youtube'), duration_seconds, release_year, mood_tags[], themes[], content_warnings[], is_featured, status, ai_proposed_at
  • signal_union_song_reactions — user_id, song_id, reaction ('saved'|'skipped'|'played'), note. Unique on (user, song, reaction) so re-recording upserts.
  • profiles.signal_union_stream_enabled + profiles.signal_union_mood_match — per-user opt-in flags. Both default false.

Service signal-union.ts:

  • Public reads: listActiveArtists, getArtistBySlug, listSongsForArtist, getStreamSongs (joined with artist for one-trip stream-card fetch)
  • Admin CRUD: createArtist, updateArtist, createSong, updateSong, deleteSong, listAllArtistsForAdmin
  • User: recordSongReaction, deleteSongReaction, listSavedSongs, getUserPrefs, updateUserPrefs

Endpoints mounted at /api/signal-union (routes/signal-union.ts):

  • Public: /artists, /artists/:slug, /stream-songs?limit=N
  • Authed user: GET /me, PATCH /me, GET /stream-songs-for-me?limit=N (mood-matched when opted in; falls back to recency rotation when there's no signal or mood_match is off), POST /reactions, DELETE /reactions/:songId/:reaction
  • Admin (super-admin only): /admin/artists (GET/POST), /admin/artists/:id (PATCH), /admin/artists/:id/songs (GET — includes drafts), /admin/songs (POST/PATCH/DELETE)

Mood matcher signal-union-mood-match.tsbuildMoodProfile(userId) reads the user's last 14 days of journal_entries.mood + satisfaction_quick_values.satisfaction_level, maps via journal-mood→song-tag weight tables (e.g., tired → reflective/spacious/melancholy, frustrated → restless/cathartic/driving; satisfaction 0-3 leans melancholy/cathartic; 8-10 leans hopeful/energetic). getMoodMatchedSongs(userId, limit) hydrates the active SU corpus and scores each song by mood_tag overlap; featured songs get a +0.05 tie-breaker. Signal strength is 'none' (skip), 'weak', or 'strong' (≥3 mood-tagged entries OR ≥5 satisfaction pings).

AI artist proposer signal-union-curator.tsproposeArtists(count) calls GPT-4o for N diverse new artists (by region / genre / era / independence) and inserts each as status='draft' with ai_proposed_at stamped. Hallucination guard: the model proposes artist METADATA only (name, bio, region, genres, palette, suggested platform link patterns) — never specific song URLs, which LLMs reliably fabricate. The human curator verifies platform pages exist and pastes real song URLs before flipping to active.

Spotify quick-addPOST /api/signal-union/admin/quick-add { url } lets the admin paste a Spotify track / album / artist URL. Backend hits Spotify's no-auth oEmbed + scrapes Open Graph tags for title + artist_name, slugifies, find-or-creates the artist (status='draft'), and creates the song record pointing at the pasted URL. Used by the QuickAddSpotifyBox at the top of /dashboard/admin/signal-union.

Spotify top-tracks lookup (signal-union-spotify.ts) — once an artist has their spotify_artist_url filled in (Identity panel in the admin editor), the curator clicks "🎵 Pull top tracks" in the Songs panel. Backend hits Spotify's Web API via the Client Credentials flow (SPOTIFY_CLIENT_ID + SPOTIFY_CLIENT_SECRET env vars; access token cached in-process until 60s before expiry) and returns up to 10 top tracks with title, duration, release_year, album, art, and the canonical track URL. The frontend SpotifyTrackPicker shows a multi-select list (top 5 fresh tracks preselected, tracks already in the corpus shown dimmed), and bulk-creates the picked ones as draft songs in parallel. Cuts curation from "research → paste each URL" to "click → click → import."

Artist outreach scaffolding (migration 260) — adds outreach_email, outreach_handle, outreach_status ('unverified'|'reached_out'|'collaborating'|'declined'), notify_when_featured (default true), share_consent (default false), and last_featured_at columns to signal_union_artists. The actual outreach sender (cron that watches reactions + fires emails/DMs when an artist's song surfaces in a user's Stream) is a future migration once the corpus has consenting artists. The admin curation page surfaces all the fields in a dedicated "Artist outreach (future sender)" panel.

Stream card SignalUnionStreamCard.tsx — fetches up to 8 stream-songs once on mount and renders one as an embedded player iframe. Provider URL translation (spotify.com/track/Xopen.spotify.com/embed/track/X; SoundCloud → w.soundcloud.com/player; YouTube → youtube.com/embed; Bandcamp passes through pre-built EmbeddedPlayer URLs). Falls back to an external "Listen on X ↗" link when the URL shape isn't recognized. Includes a 🔖 save button that POSTs a 'saved' reaction.

Stream integration (dashboard/stream/page.tsx) — <SignalUnionStreamCard> injected after every 18th entry (offset by 9 from the every-12 Graphene cards so they never collide). Gated by both the user's signal_union_stream_enabled profile flag AND the StreamFilterPanel's 🎵 Hide Signal Union toggle.

Settings (dashboard/settings) — SignalUnionPreferencesSection exposes both toggles (stream-enable + mood-match) as Lovio-style switch cards. Mood-match is disabled when the parent stream-enable toggle is off.

Admin /dashboard/admin/signal-union — collapsible artist list with inline editor, status flip (active/draft/retired), songs panel per artist with embed-URL auto-provider-detection and mood-tag chips (starter vocabulary: reflective, energetic, melancholy, hopeful, grounding, restless, tender, driving, spacious, cathartic).

Seed scripts/seed-signal-union.ts — Phase 1 seed plants Norman Sann as a draft artist. Real songs land via the admin page after the curator has the embed_url for each track.

Looking Glass — time-travel portal to notable historical figures

A full-bleed shimmer experience that surfaces verified quotes from public-domain letters, journals, speeches, and memoirs — Mark Twain, Van Gogh, Frederick Douglass, Ada Lovelace, etc. — animated as if you were watching them write. v1 is verified-only: every quote cites a public-domain source. No AI-imagined-in-voice mode at launch.

Schema (migration 255, 256, 257):

  • looking_glass_figures — curated people (slug, display_name, bio, era, field, scene_preset, writing_instrument, font_family, palette_hex, status, ai_proposed_at + ai_proposed_by_model columns from 256)
  • looking_glass_quotes — verified quotes (text, source jsonb, context, original_date for anniversary lookup, is_featured, ai_proposed_at + ai_proposed_by_model from 256)
  • looking_glass_saves — user bookmarks
  • looking_glass_scene_assets (257) — one gpt-image-1 backdrop per scene_preset, shared across every figure with that preset

Service looking-glass.ts:

  • Public reads: listActiveFigures, getFigureBySlug, listQuotesForFigure, getQuoteById, getFeaturedQuoteForToday (deterministic UTC-date pick)
  • Anniversary surface: getAnniversaryQuotesForToday returns every quote whose original_date MM-DD matches today, with computed years_ago — powers the Stream's floating "N years ago today" portal cards.
  • Admin CRUD + user saves.

Endpoints mounted at /api/looking-glass (routes/looking-glass.ts):

  • Public: /figures, /figures/:slug, /featured, /anniversaries, /quotes/:id, POST /saves, DELETE /saves/:quoteId, GET /saves, /scene-assets
  • Admin (super-admin only): /admin/figures (GET/POST), /admin/figures/:id (PATCH), /admin/quotes (POST/PATCH/DELETE), POST /admin/propose-figures, POST /admin/propose-quotes, POST /admin/scene-art/:preset/regenerate

Frontend:

  • /looking-glass — landing with today's featured + figure grid
  • /looking-glass/[slug] — figure intro + quote list
  • /looking-glass/[slug]/[quoteId] — the full-bleed experience
  • /dashboard/admin/looking-glass — curation surface (add figures, add quotes with source citation, status flip)
  • Route layout loads Caveat (cursive) + Special Elite (typewriter) via next/font scoped to /looking-glass/*

Experience component LookingGlassExperience.tsx — 5-stage timeline (shimmer-in → scene reveal → text materializes via CSS clip-path sweep → source citation slides up → action row). Font + scene + palette all driven by the figure row. On mount it fetches /api/looking-glass/scene-assets and uses the matching preset's image_url as a full-bleed backdrop (dimmed brightness 0.55 so the foreground portrait + quote still own the composition); falls back to the CSS gradient composition when the scene hasn't been rendered yet. The palette shimmer overlay sits on top.

Scene art looking-glass-scene-art.ts — six scene presets (writing_at_desk, window_pensive, lab_bench, campfire_notebook, ship_quarters, parlor_chair) each have a hand-tuned painterly prompt rendered via gpt-image-1 at 1536×1024 standard quality (~$0.04/call). Uploads to Supabase Storage looking-glass bucket at scenes/{preset}.png with a cache-busted URL upserted into looking_glass_scene_assets. Admin regenerates on demand via the Scene-art panel in the curation page.

AI curator looking-glass-curator.tsproposeFigures(count) + proposeQuotesForFigure(figureId, count) call GPT-4o with a system prompt that names common hallucinations to avoid (Gandhi "be the change", Einstein "definition of insanity", etc.) and requires confidence_note + work_title + date + archive_url per quote. All proposals land as drafts with ai_proposed_at stamped — human verification of the source citation against the archive document is the load-bearing step; the verified-only promise depends on it.

Stream integration LookingGlassStreamPortal.tsx<LookingGlassPortalStrip /> mounted above the Stream's horizontal scroller. Hits /api/looking-glass/anniversaries on mount; renders a card per match. Self-hides on days with no anniversary matches. Reader can suppress via the "🔮 Hide Looking Glass" toggle in StreamFilterPanel (hideLookingGlass field on StreamFilterState, persisted to localStorage under hj:stream-filters-v1). The same panel hosts a "🎬 Hide Graphene shows" toggle (hideGrapheneShows) that suppresses the every-12-entries Graphene movie-poster promo cards on /dashboard/stream.

Seed script scripts/seed-looking-glass.ts — plants Mark Twain + Van Gogh × 3 verified quotes each for end-to-end testing. Full ~70-quote curation pass goes through the admin UI.

Bootstrap admin emails

Auto-promotes a configurable list of email addresses on every backend boot — for granting admin access to trusted collaborators without ad-hoc SQL. Three independent env vars (a single email can appear in multiple lists):

  • BOOTSTRAP_ADMIN_EMAILSrole='super_admin'
  • BOOTSTRAP_CREATOR_EMAILSis_creator=true
  • BOOTSTRAP_BUSINESS_PARTNER_EMAILSis_business_partner=true

The roles UI is the durable management surface; env vars are the bootstrap path for the very first super_admin and for collaborators provisioned before the UI was opened.

  • Service: apps/backend/src/services/bootstrap-admins.ts — paginates auth.admin.listUsers() to find each email, then upserts the right field. Idempotent. Self-healing: emails not yet signed up are reported as not_signed_up and re-checked on the next boot.
  • Boot hook: called fire-and-forget from apps/backend/src/index.ts right after app.listen(). Logs counts.
  • Manual re-run: POST /api/admin/bootstrap-admins (super-admin only) — runs the same sweep on demand. Returns { results, granted, already_granted, not_signed_up, errors }.
  • Why env vars (not committed code): grants are sensitive + rotate; keeping the lists in env keeps them rotatable without a deploy + out of git history.

Conversion Rate Tracking

Admin-controlled conversion rates between drops and other units.

  • Backend: apps/backend/src/routes/admin-conversion.ts

Token Tracking

Per-user OpenAI/AI usage and cost tracking.

  • Helper: apps/backend/src/utils/tokenTracking.ts
  • Migration: 017_add_token_tracking.sql
  • Reference: docs/TOKEN_TRACKING.md

Authentication

Supabase Auth with custom middleware. Frontend client uses createBrowserClient from @supabase/ssr (migrated off the deprecated @supabase/auth-helpers-nextjs in early 2026 — single helper at apps/frontend/src/lib/supabase.ts wraps it).

  • Middleware: apps/backend/src/middleware/auth.ts
  • Backend: apps/backend/src/routes/auth.ts
  • Profiles auto-provisioning (migration 352): every signed-up user is assumed to have a profiles row — the OAuth callback, chat-settings endpoints, and dozens of frontend .from('profiles').select('role').eq('id', user.id).single() calls all depend on it. That row is created by a handle_new_user() trigger on auth.users (AFTER INSERT → INSERT INTO profiles (id) ON CONFLICT DO NOTHING, SECURITY DEFINER). This trigger originally lived only in the prod DB (dashboard-created, uncommitted) and silently stopped firing ~2026-05-21, leaving every signup since without a profile → profiles 404s + GET /api/chat/settings 500s. Migration 352 codifies the trigger in version control + backfills the gap. Migration 437 extends the trigger to also copy landing_page_variant + a 13+ age-attestation timestamp out of NEW.raw_user_meta_data (the options.data bag signup passes) — the client used to write those to profiles directly from the browser, which failed with RLS 42501 whenever email confirmation left the just-signed-up user without a session (staging). Rule of thumb: any profile column a brand-new signup needs set must ride in signup metadata + be stamped by this SECURITY-DEFINER trigger, never a client-side profiles upsert (anon → RLS reject). Landmine: if you ever see fresh-user 404/500s on profile reads, check this trigger exists on prod first. Defensive companion: profile-reads that can hit a brand-new user should use .maybeSingle() + a default, never .single() (which 500s on 0 rows) — see chat.ts settings handlers.
  • Frontend pages: auth/signin · signup · callback · sso-bridge · forgot-password · reset-password
  • Cross-domain SSO bridge (/auth/sso-bridge + backend POST /api/auth/sso-bridge): when a user clicks "Sign In" from graphene.fm or write.cafe's slim-nav, they're routed through https://www.hivejournal.com/auth/sso-bridge?return_to=... (tight ALLOWED_HOSTS allowlist on the backend). If an upstream HJ session already exists, the backend mints a Supabase magic link for the user's email pointing at return_to; the browser navigates there and Supabase sets a session cookie on the brand domain. If no upstream session, the bridge redirects to /auth/signin?next=<bridge URL>&brand=<derived> — signin loops back through the bridge after authentication. The brand= param is the key to keeping chrome consistent across the chain: see the brand-aware fallback bullet below.
  • Brand-aware auth chrome via ?brand= override (lib/branded-surface.ts): useBrandedSurface keys off window.location.hostname by default, so hivejournal.com/auth/signin renders the HJ default look. The SSO bridge fallback breaks this — a write.cafe user clicking Sign In ends up at hivejournal.com/auth/signin (no session on HJ yet) and would lose write.cafe chrome mid-flow. Fix: useBrandedSurface({ brandOverride }) honors ?brand=writecafe|graphene from the URL. The SSO bridge derives brand from return_to's host via brandFromUrl() and passes it forward. /auth/signin + /auth/signup preserve the brand param on internal cross-links (signup ↔ signin) so the surface stays consistent across the full chain. Result: a write.cafe user gets "Welcome back to the café" + ☕ amber chrome on hivejournal.com/auth/signin, then loops back through the SSO bridge to land on write.cafe signed in.
  • Google OAuth (PKCE flow via supabase.auth.signInWithOAuth({ provider: 'google' })): "Continue with Google" buttons on both /auth/signin + /auth/signup ride above the email form with a "— or with email —" divider. Redirect URL is {currentOrigin}/auth/callback?next=...&brand=... so the round trip stays on the brand domain (write.cafe → Google → write.cafe). The callback is a server Route Handler at /auth/callback/route.ts — not a client page. The PKCE code_verifier lives in @supabase/ssr's HttpOnly chunked cookies that aren't reliably readable from a client component after the cross-site redirect, so the server pattern (createServerClient + cookies() from next/headers) is the canonical Supabase Next.js fix. Handler reads code + next, calls exchangeCodeForSession, then 302s to next or a brand-aware fallback (graphene→/graphene, write.cafe→/write-cafe, hivejournal→/dashboard). Signup path enforces the 13+ COPPA checkbox before redirecting to Google — OAuth can't be used to bypass the age gate. Provider config lives in the Supabase dashboard (Auth → Providers → Google) with the OAuth client ID/secret. The Google Cloud Console OAuth client gets ONE redirect URI: Supabase's own https://<project>.supabase.co/auth/v1/callback. Supabase Auth → URL Configuration allowlists the three brand-domain /auth/callback paths so redirectTo is accepted.
  • Account-collision handling on OAuth callback: when a Google sign-in attempts to attach to an email that already exists on an unverified email-password account, Supabase refuses to auto-link (security floor — prevents OAuth-based takeover of unverified accounts). The callback detects this via error-message matching (already registered / already exists / user_already_exists code) and redirects to /auth/signin?message=... with actionable guidance ("Sign in with your password, then link Google from settings"). The signin message banner is amber/info-tone (not green/success) so the collision case reads correctly.
  • Manual identity linking from settings (SignInMethodsCard in dashboard/settings): once signed in, users see a "Sign-in methods" card listing their connected providers (Email + Google) with state badges. A "Link Google" button on the un-linked row calls supabase.auth.linkIdentity({ provider: 'google' }) which kicks off an OAuth flow that attaches the new identity to the current user instead of creating a new auth.users row. Redirects back through /auth/callback?next=/dashboard/settings so the user lands on the same card with the freshly-linked state visible. Requires "Manual linking" enabled in Supabase Auth settings; if disabled, the call surfaces an actionable error in-card rather than a cryptic supabase message. Closes the loop on email-first users who want Google fast-path without re-creating their account.
  • Post-callback age-attestation gate (/auth/confirm-age + check in /auth/callback/route.ts): closes the COPPA-floor gap where a brand-new user clicking "Continue with Google" on /auth/signin could create an account that never attested 13+ (the email-password form + the /auth/signup Google button both enforce the checkbox before redirect, but signin can't). After the callback's exchangeCodeForSession succeeds, we read the just-signed-in user's profiles.age_attested_13plus_at; if null, 302 to /auth/confirm-age?next=... instead of straight to the dashboard. The confirm-age page is a minimal form (checkbox + Continue), upserts the timestamp, then router.replace(next). Best-effort — profile-read failures don't block the redirect (dashboard's own checks will catch).
  • OAuth funnel tracking (/auth/callback/route.ts imports posthog-node): the password-path signup/signin handlers fire signup_completed / signin_completed PostHog events client-side, but the OAuth round-trip is server-side (the callback 302s before any browser script runs). Closes the blind spot — after the callback's exchangeCodeForSession succeeds, we fire the matching event server-side via posthog-node's captureImmediate() so the funnel chart isn't underreporting Google conversions. Signup-vs-signin distinction is heuristic: user.created_at within 60s of the callback ⇒ fresh signup; older ⇒ returning signin. Properties: { source: 'oauth_callback', method: 'google' } so the data team can break out OAuth vs password in the funnel. Lazy-init PostHog client + no-op when NEXT_PUBLIC_POSTHOG_KEY is unset (dev/CI safe).
  • Global PostHog identity sync (PostHogIdentitySync in components/analytics/PostHogProvider.tsx): the password-path handlers also call identifyUser() client-side to merge a user's anonymous pre-signup events with their authenticated identity; OAuth bypasses that step too, leaving anonymous browsing orphaned. Fix: a small sub-component inside PostHogProvider (so every route is covered) subscribes to Supabase's onAuthStateChange + does an initial getUser() read on mount. On any signed-in state, calls posthog.identify(user.id, { email }) if the current distinct_id doesn't already match; on sign-out, calls posthog.reset() so post-logout anonymous events don't attach to the previous identity. Covers OAuth callback completion (Supabase fires SIGNED_IN once the session cookie lands), deep links, page refreshes — anywhere a user might arrive authenticated without having clicked the password form.

Cron Jobs

Scheduled tasks driven by Vercel Cron and external secrets.

Deploy-durable audiobook renders — graceful drain + render recovery

A full render is fire-and-forget background work that can run minutes; before this, a Railway deploy (SIGTERM → instant death, the backend had no signal handlers) mid-render left the season stuck on season_publishings.status='rendering' forever, abandoning the creator's work. Two layers:

  • Graceful drain (lifecycle.ts, wired in index.ts) — SIGTERM/SIGINT flip a draining flag (isDraining()), server.close() lets in-flight HTTP finish, shutdownAnalytics()/shutdownPool() flush, then exit with a 4.5s hard deadline (before Railway's SIGKILL). Makes shutdown clean; doesn't make a long render finish (grace window is seconds).
  • Render recovery (render-recovery.ts, migrations 361 + 362) — the durable fix, covering both audio renders AND M4B/ACX exports via one generic heartbeat + reclaim-sweep core. withRenderHeartbeat() / withAcxExportHeartbeat() refresh a *_heartbeat_at column every 30s while the job runs (interval dies with the process → stale heartbeat = orphaned). The single render_recovery cron (every 2min + ~20s after boot; CRON_REGISTRY + toggle) runs recoverStuckRenders() (season_publishings.status) + recoverStuckAcxExports() (story_seasons.acx_export_status): finds jobs stuck rendering with a stale heartbeat and resumes them — audio reuses cached TTS segments, ACX recompiles the M4B from existing chapter audio (no re-TTS), so a resume picks up where it left off. Optimistic claim (compare-and-set on the heartbeat) makes it safe under the deploy-overlap twin / concurrent sweeps. A *_attempts counter caps auto-resume (a user-triggered job resets to 0; the sweep bumps it; after 3 it marks the job failed with an actionable retry message instead of looping). Each sweep is scoped to its own 'rendering' status column — audio = season_publishings.status, ACX = story_seasons.acx_export_status (video/other statuses untouched). Complements the existing read-only stuck-publishings detector in admin-pipeline-health.ts (which surfaced the problem; this fixes it). The reclaimStuck(config) core extends to any future long job by adding a config block.

Cron toggles UI — per-cron runtime kill switch (/dashboard/admin/crons)

DB-backed per-cron kill switch UI. Solves the "$10/few-hours OpenAI bleed" problem where an LLM-heavy cron is running too hot and the only stop-bleed lever was an env-var change + a Railway redeploy (10+ minutes wall time). Now: flip a DB row, the in-process cache TTL bursts within ~60s, the cron skips its next tick. Migration 342_cron_toggles.sql, services in services/cron-toggles.ts, routes in routes/cron-toggles.ts (super-admin-gated via the canonical profiles.role === 'super_admin' check, view-as-aware via req.impersonator?.id ?? req.user?.id).

  • CRON_REGISTRY — 11 entries (persona_sim, auto_render, writer_fleet, sleep_story_gen, cafe_floor_seed, persona_browse_cron, content_manager, platform_director, reader_engagement, infra_monitor, marketing_advisor) each with key, label, description, group (llm_content / autonomous_brief / notif_digest / maintenance / misc), envVar, defaultEnabled, cost_per_tick_usd. Adding a new cron means: register it here + wire isCronEnabled(key) at the entry point + add a CRON_LABELS entry in services/system-health.ts for the dashboard. CLAUDE.md repo-root section documents the pattern alongside the heartbeat requirement.
  • Resolution precedence: DB row > env var > registry default. The DB row wins so admin can flip ON→OFF AND OFF→ON without touching env (a cron that's env-gated default-off can be turned on temporarily by writing a DB row). 60s in-process cache, invalidateCronToggleCache write-through on toggle so the toggle action itself is immediate. setCronToggle(key, enabled, notes, by) upserts + busts cache.
  • UI — grouped by burner-tier with source chip per row (db = amber / env = sky / default = gray) and ~$X.XX/tick cost annotation when registered. One-tap toggle with an optional notes field persisted on the next click (e.g. "OpenAI bleed Jun 03 — investigating"). Linked from /dashboard/admin/llm-spend, /dashboard/admin/system-health, /dashboard/admin/creator-usage, and from the Cmd-K palette under super-admin entries (cron / cost / expense / toggle synonyms).
  • Backend wiringapps/backend/src/index.ts removed the boot-time env-check setTimeout early returns that gated the autonomous-brief loops, so the intervals always start; the DB toggle can flip OFF→ON without a redeploy. Each cron entry point now calls await isCronEnabled(key) and skips its tick when false.
  • Why this exists — context: PR #417 shipped it after a multi-hour debugging session traced a recurring spend spike to runPersonaSimLoop (1h cron, no env gate) + runAutoRenderLoopOnce (4h cron, no env gate). The env-var-only kill switch took ~10 minutes per change; the DB row takes ~60s. PR #422 fixed a broken super-admin check + a dead /openai-spend link in the page header.

OpenAI model config

Central config for image-gen model + quality lives at apps/backend/src/lib/openai-models.ts. Every callsite (~16 across 11 files: graphic-novel, odessa, user avatars, tone-packs, persona-poses, story-cover-images, season-media, cafe-bento, cafe-random-prompts, daily-comic, workout-window mosaic-generator) imports IMAGE_MODEL + one of IMAGE_QUALITY_LOW / IMAGE_QUALITY_STANDARD / IMAGE_QUALITY_HIGH from this file. The next model deprecation is one Vercel env-var change, not a 16-file sweep.

  • Env vars (all optional): OPENAI_IMAGE_MODEL (default gpt-image-1), OPENAI_IMAGE_QUALITY_LOW|STANDARD|HIGH (defaults low|medium|high).
  • Quality tier mapping: LOW = avatars / tone icons / persona poses; STANDARD = story covers / panels / comics / bento / mosaics; HIGH = season posters / character reference sheets.
  • Rationale for env-vars-not-DB-config: live tuning isn't a current need, env-var lifecycle (set → redeploy → ~60s live) is plenty fast, no per-call DB roundtrip on the hot image-gen path. If we later need A/B'ing a new model on 10% of users, the file becomes a function reading from site_settings with a 60s in-memory cache.

Env Flags admin dashboard

Super-admin visibility into which env-gated features are currently ON/OFF in the deployment, without trawling Vercel/Railway dashboards.

  • Backend: GET /api/admin/feature-flags in routes/admin.ts returns each tracked flag with { key, label, description, kind, value, enabled, category }. Backend-side env vars (WRITER_FLEET_, CAFE_FLOOR_SEED_, AI_PERSONA_AUTO_POSES_ON_CREATE, OPENAI_IMAGE_*) read at request time from process.env. Secrets / credentials are NOT surfaced.
  • Frontend: /dashboard/admin/feature-flags renders the response grouped by category with a status pill per row (✓ Enabled / ○ Disabled for booleans, value chip for non-boolean settings). Concats NEXT_PUBLIC_* build-time vars (currently just NEXT_PUBLIC_CHROME_WEB_STORE_URL) into the same table since the backend can't see those.
  • Linked from the FeatureMenu as "🎛️ Env Flags" (distinct from the pre-existing "🚩 Feature Flags" entry which is the feature catalog, not env state).

Public & Marketing Pages

Landing Page (A/B Variants)

Multi-variant marketing landing pages.

  • Variant pages: variant-b/ (current default)
  • Migrations: 033_add_landing_page_variant.sql, 034_add_variant_a_text_settings.sql
  • Routes: /, /landing/a, /landing/b, /landing/c, /variant-b

Audience Landings (/for-*) + shared pitch/landing kits

Profession-specific front doors into the platform. Discoverable from the /for hub, the homepage (EcosystemHome "For the work you do" section), the /features catalog ("For your work — front doors by profession" category), the SiteFooter "For the work you do" pill row, and the admin shortcut list (dashboard/admin/page.tsx).

  • Hub: /for — cards routing to every audience. When adding a page, keep its AUDIENCES array + the footer AUDIENCE_LINKS + the homepage AUDIENCES in sync.
  • Public conversion landings (audience-facing, with a design-partner lead form): /for-therapists (Throughline — see the Throughline section for the full backend), /for-coaches (DreamPro Coaching recruitment), /for-teachers (umbrella: journaling + student wellbeing + the consent-based "signal, never their words" metadata model → routes to the subject tools), /for-english-teachers (AI revision game + Comic Generator + VR characters from the Classics library), /for-science-teachers (real citizen science grounded in Open Energy + the world map, classroom-safe).
  • Unlisted conversion landing (public but deliberately NOT surfaced in the hub/homepage/features/footer — a direct link for warm intros): /for-guidesLantern, the spirituality / embodied-coaching skin on the DreamPro coaching engine (guides & facilitators who run circles/masterminds, not fitness). Same landing kit; own-voice encouragement is a headline value. Posts to POST /api/coaching/coach-interest with source: 'for_guides' → pools into coach_interest (same super-admin review as /for-coaches). Brand is a single BRAND = 'Lantern' const (display name; domain TBD, decoupled). Quiet launch until a first guide runs a real cohort; full brand rationale + "the Wave" collective-challenge concept in docs/product/COACHING_BRAND_MAP.md.
  • Internal pitch decks (unlisted, noindex, shared directly): /for-john (DreamPro Coaching proposal for John Rowley; a header callout links to /for-coaches) + /for-ben (the writing-platform / Graphene pitch). Both a TL;DR/Full tab experience.
  • Two shared kits (DRY — sibling modules, one per page type):
    • @/components/pitch — the internal-deck kit: PitchTabBar (sticky TL;DR/Full tabs, accent-parameterized), Panel (accordion, optional Live/Designed chip), Scoreboard (status-tinted tiles that deep-link into panels), Section, LiveChip/DesignedChip, VisualHeading, MiniBullets, Aside, usePitchPanels (open-state Set + jump-to-#pitch-panel-<id> scroll). Used by /for-john + /for-ben.
    • @/components/landing — the public-landing kit: LandingShell (fixed photo or gradient backdrop + Navbar/SiteFooter), HeroGlow, StatusColumns (live-today vs on-the-roadmap), FaqAccordion (native <details>), DesignPartnerForm (configurable fields/endpoint/source/card + optional extraPayload/onSubmitted/validateEmail hooks so each page keeps its exact analytics + funnel-token behavior). Used by all four landings + the /for hub. A new /for-<audience> page is ~one page file + a layout on this kit.
  • Lead capture: the therapist + teacher landings POST to POST /api/providers/interest (routes/providers.ts), keyed by source (for_therapists / for_teachers / for_english_teachers / for_science_teachers), pooling into provider_interest. The coaching skins (/for-coaches, /for-guides) instead POST to POST /api/coaching/coach-interest (routes/coaching.ts) with source (for_coaches / for_guides), pooling into coach_interest (super-admin review at the coaching dashboard). Roadmap (product_tasks): per-audience funnel views when volume justifies splitting them out.
  • Routes: /for, /for-therapists, /for-coaches, /for-guides (unlisted), /for-teachers, /for-english-teachers, /for-science-teachers, /for-john, /for-ben.

Journaling comparison SEO cluster (/compare/journaling + matchups + roundup)

Comparison-shopper acquisition pages targeting journaling-app queries ("hivejournal vs rosebud", "best journaling apps 2026"). Built 2026-07-16 as step 1 of the journaling-space advertising analysis — capture high-intent comparison traffic organically before any paid spend. House rule: honesty first — competitor strengths are stated by name, our gaps (no native app in stores, no voice-to-text entry) are on every page, and prices link to vendor sites.

  • Data module (single source of truth): lib/journaling-compare.tsJOURNALING_COMPETITORS (Rosebud, Day One, Stoic: pricing, free tier, strengths/tradeoffs, pick-them-if/pick-us-if, sources), FEATURE_ROWS (the shared matrix), HIVEJOURNAL_SELF (our column incl. honestGaps), PRICES_VERIFIED (bump when re-verifying prices — verified 2026-07-16). Adding a competitor = one entry here; the hub matrix, a new matchup page, and the sitemap all pick it up.
  • Hub: /compare/journaling — full matrix vs all competitors + honest per-app verdict cards. Server component (SEO metadata).
  • Matchup pages: /compare/[matchup]/compare/hivejournal-vs-{rosebud,day-one,stoic} via generateStaticParams (+ dynamicParams=false so unknown slugs 404). Two-column matrix + "what they do better" + both-direction verdicts. Next-15 params is awaited.
  • Roundup: /best-journaling-apps-2026 — category picks (Day One / Rosebud / Stoic / Apple Journal / 750 Words / us, ours explicitly disclosed) + the matrix.
  • Shared table: components/compare/CompareTable.tsx — extracted from /compare (which now imports it); cell vocabulary yes/no/unique/na/literal-note.
  • Wiring: sitemap lists the whole cluster (imports MATCHUP_SLUGS); SiteFooter links the roundup; /compare (writer-platform angle) cross-links the hub and vice versa.

About That — embeddable page-audio player (by Emberkiln)

One script tag gives any third-party page an audio player that speaks ABOUT that page: summary, eli10, or the flagship pitch_panel (owner's cloned voice pitches the page's idea, a stock INVESTOR voice pushes back — original show format, prompt-enforced no-real-TV-references). Lazy render on first listener tap, cached by sha256(kind+voice+tone+extracted text). Task 316c8cfa (P0), v1 2026-07-16. Full doc: docs/product/ABOUT_THAT.md.

  • Migration: 493_about_that.sqlabout_that_embeds (domains allow-list, voice_mode own_clone|narrator, tone, enabled kinds, daily cap) + about_that_renditions (unique (embed_id, content_hash, kind) = cache + concurrent-render claim). RLS on, backend-only.
  • Pure core (golden-tested): services/about-that-core.ts — dot-boundary domain matching (evil-example.comexample.com), cache keys, clamps (8k in / 3.8k script / 24×600 panel lines), prompt builders, parsePanelScript. Tests: about-that-core.test.ts.
  • Service: services/about-that.ts — embed CRUD, getOrRenderRendition (domain gate on the TARGET url — an embed only narrates its own domains; per-embed daily cap; error rows = per-content tombstones; stale rendering claims self-clear at 10 min), voice resolution (clone w/ consent ≥ 2 → getActiveVoice; narrator → getHouseVoiceId), namespaced IP throttle in rehearsal_rate_limits (no migration). Reuses ttsSegment/concatMp3Buffers/uploadAudio (→ season-assets/about-that/), fetchReadableText (SSRF-guarded), complete (feature_keys about_that.script.*, about_that.render.*; TTS quota billed to the embed OWNER).
  • Routes: routes/about-that.ts at /api/about-that — owner endpoints requireCreator; public GET /embed/:id/config + POST /embed/:id/rendition (200 ready / 202 rendering+poll / 503 when ELEVENLABS_API_KEY unset).
    • Embed metadata GET (partner editor-preview / provisioning host-check): public GET /embed/:id/meta?host={ exists, preset, kinds:[{kind,label}], languages, host_allowed }. Deliberately never dumps allowed_domains — a caller passes ONE host and learns only whether it's allowed. Missing/paused embed → { exists:false } at 200 (graceful degrade, not 404). getEmbedMeta in about-that.ts. Contract: crosstalk/contracts/about-that-embed.md (QuickSites about_that block consumes it). No migration.
    • Partner provisioning (migration 549) — QuickSites calls the owner-scoped audio endpoints (/welcome, /testimonial, /testimonials) on an HJ owner's behalf. Two-factor, both required or it fails: X-Partner-Key (shared secret env PARTNER_QUICKSITES_SECRET, constant-time safeEqual, proves it's QuickSites) + X-Partner-Grant (an owner-minted token; HJ stores only its SHA-256, raw shown once). Fail-closed: an unset secret makes the whole partner surface dark, so nothing activates until the env is set on both sides. Pure core about-that-provisioning-core.ts (golden-tested: hashGrantToken, safeEqual, checkGrant revoked→401/scope→403/embed-narrow→403, normalizeVoiceBases drops third_party, estCostUsd @ $0.30/1k). Service about-that-provisioning.ts (mintGrant/listGrants/revokeGrant/resolveGrantRecord/recordPartnerRender/getPartnerUsage). Middleware about-that-partner.ts ownerOrPartner — accepts owner JWT (creator-gated, unchanged) OR a partner grant (acts as the grant's owner, enforces embed-narrowing via req.params.id). Consent v2 bright line: welcome = owner's own(self)/narrator, testimonial = always narrator — a partner grant can never reach third_party voice through these rails. Owner grant UI: POST|GET|DELETE /api/about-that/grants + a per-embed "🔌 Connect QuickSites" panel (generate token → copy once → list → revoke). B2 rollup feed: GET /api/partner/usage (routes/partner.ts, partner-key-only) → per-owner render/char/est-cost counts (no PII, no script text); responses gain a B1 usage envelope. billing_mode owner (default, HJ meters owner quota) vs partner (plumbed; accrual/packs deferred to the paid-mode infra). Contract: crosstalk/contracts/partner-provisioning.md. Apply migration 549 + set PARTNER_QUICKSITES_SECRET (both sides) to activate.
    • Talking Demo Tier 2 — Phase A (migration 550) — QuickSites auto-builds a site → generates a stepped tour script from its blocks → HJ renders per-step audio narration. POST /api/partner/talking-demo/render (routes/partner.ts) — partner-key required, X-Partner-Grant optional (→ owner's own clone, else house); body { instance_ref?, steps:[{caption,say,dwell_ms?}], voice?:'house'|'owner_clone' }{ instance_id, steps:[{caption,say,audio_url}], mp4_status:'skipped', voice_basis, usage }. GET /talking-demo/:instanceId polls. Pure core talking-demo-core.ts (golden-tested: normalizeTourSteps clamp ≤24/~300 mirroring QS's generator, tourContentHash, tourCharCount); service talking-demo.ts reuses narrateLine (render-once, cached per voice::line) + the provisioning auth/billing. Per-instance + ephemeral (30-day sweep) — deliberately NOT the studio_demos approve-before-publish catalog. Unchanged tour in the same voice = free cache hit (billed:false); owner-clone-without-a-consented-clone falls back to house + honest voice_basis:'narrator'. House (no-owner) renders bill partner-direct (nullable owner_user_id on about_that_partner_renders; surfaced in the B2 feed's partner_direct bucket). Per-partner daily cap TALKING_DEMO_DAILY_CAP (default 300) → 429. Phase B — async caption-card MP4 (migration 551): pass want_mp4:true (+ optional title) → /render returns mp4_status:'rendering' and the MP4 renders fire-and-forget (talking-demo-video.ts renderTalkingDemoMp4 — instance-scoped variant of demo-video.ts, reuses the golden-tested demo-video-args builders + renderHtmlToPng caption cards + bundled ffmpeg, driven by Phase-A's cached audio_url per step); poll GET /:instanceId for mp4_status:'ready' + mp4_url/poster_url. Heavy ffmpeg/Chromium deps dynamic-imported only when an MP4 is requested. Caption cards render on the current headless-shell infra. Phase C — real page-screenshot MP4: pass page_url (the QS draft site) with want_mp4:true → HJ opens it in the shared headless Chromium (getSharedBrowser) and screenshots per step (SSRF-guarded via assertPublicUrl, now exported from link-preview.ts). Positioning: QuickSites mirrors HJ's demo-actions vocabulary at the block level — each step's optional action (scroll:#services, highlight:.price, or a bare #anchor, parsed by parseAction) lands the shot on the section its narration describes; no action → proportional scroll (works before QS emits actions). Per-step best-effort → a failed shot degrades to a caption card; unsafe/missing page_url degrades the whole MP4 to Phase-B cards. action folds into tourContentHash (+ page_url). Contract: crosstalk/contracts/talking-demo-render.md. Internal super-admin preview (no partner secret) — dogfood the render from HJ: there's no user-facing Talking Demo page on HJ (it's a partner API for QuickSites), so /dashboard/admin/talking-demo (linked from the Demo Studio header) drives the same renderTalkingDemo pipeline via super-admin POST /api/studio-demos/talking-demo/preview + GET .../preview/:id (routes/studio-demos.ts) — paste stepped tour JSON, pick house/your voice, optional page_url + want_mp4 → plays per-step audio inline + polls/embeds the MP4 reel. Uses a dedicated internal-preview partner (isolated from QS usage/caps) with ownerUserId = the caller, so voice:'owner_clone' resolves your own consented clone. No migration (reuses talking_demo_instances). End-to-end smoke PASSED both sides 2026-07-21 (audio 5/5 + real-screenshot MP4 reel rendering→ready, QS parse 1:1). Apply migrations 550+551 + set PARTNER_QUICKSITES_SECRET.
    • Book pitch — the author's clone pitches their book (migration 552) — every public write.cafe story page can mount an About That player where the author's own consented voice clone pitches the book while a curious reader pushes back (the new author preset — AUTHOR / READER "The Reading Room", code-validated in about-that-core.ts, no migration for the preset like every other). A surface WE control → an instant About That install base + direct synergy with the Lovio clone-consent flow authors already do. Consent posture = PER-STORY OPT-IN (about_that_pitch_enabled on cafe_contest_submissions, migration 552): pitching is a NEW use of the clone beyond its original consent, so nothing renders until the author flips it on that story, and enabling REQUIRES a consented clone (≥ JQ_VOICE_MIN_CONSENT_VERSION, else 409). Service about-that-book-pitch.ts: one reserved about_that_embeds row per author (name='__book_pitch__', preset='author', voice_mode='own_clone', found/created idempotently) serves all their opted-in stories; each renders its own pitch from its own body. Reuses the whole rendition pipeline (content-hash cache, TTS billed to the author, daily cap, error tombstones) via a new owned-surface sourceText path on getOrRenderRendition — the story body is fed directly because the reader page is client-rendered (URL extraction would starve the script); sourceText is server-only (never plumbed from the public rendition endpoint), so it can't be used to farm TTS for arbitrary text. Endpoints: POST /api/about-that/book-pitch { storyId, enabled } (auth + story-ownership, NOT requireCreator — cafe writers aren't flagged creators); public GET /book-pitch/:handle/:storyId (state) + POST /book-pitch/:handle/:storyId/rendition (render/poll, IP-throttled). Frontend: BookPitchPlayer.tsx self-hides on the story page unless opted in; author toggle "🎙️ Pitch in my voice" on /write-cafe/my-entries (public stories only). Apply migration 552. First surface of the "we-control-the-surface" About That P0 pair.
    • Coaching-skin embeds — the coach's clone pitches their program (no migration) — the sibling P0. A new coaching preset (COACH / SEEKER "The Discovery Call", code-validated in about-that-core.ts): the coach pitches their program in their own consented clone while an AI prospective client probes results, timeline, cost, and fit. Unlike book-pitch, coaching pages are the coach's OWN external site, so this rides the standard embed flow (coach creates an embed on the "Coach" preset via /dashboard/about-that, voice_mode='own_clone', allows their domain, drops the loader on their program page) — no owned-surface provisioning needed. Niche-agnostic (fitness/Workout Window, spirituality/Lantern, career) — John Rowley's DreamPro page narrated by John = flagship case study. Vertical landing /about-that/for-coaches (mirrors for-job-seekers / for-real-estate) + a card on the /about-that showcase. No migration (presets are code-only). Onboarding = the coach records a Lovio clone + creates the embed.
  • Frontend: loader public/about-that.js (data-embed, optional data-url/data-host/data-width → 148px iframe); player /about-that/player/[embedId] (noindex, useSearchParams inside Suspense, 5s poll ≤3 min); dashboard /dashboard/about-that; landing /about-that (every player footer links here — the viral loop).
  • "Try it out" wall (migration 494, about_that_tries): anonymous landing-page demo — anyone submits one website URL, gets a Pitch Panel (house voice FOUNDER), displayed in a public gallery with the snippet; hard-capped at TRY_GALLERY_CAP (25) non-error tries ever, then the form flips to signup. Endpoints GET/POST /api/about-that/try, GET /try/:id (202 + background render + poll — renderTryInBackground is fire-and-forget so proxy timeouts can't kill it). Guards: normalizeTryUrl dedupe, 3 submissions/IP/day, OpenAI moderation on extracted text pre-voice, cap-race recount. UI: TryItClient.tsx on the landing — gallery cards render page-visual → arrow → player: og_image_url (migration 497) populated fire-and-forget at submit via getLinkPreview (SSRF-guarded, link_previews cache), favicon-tile fallback + onError for dead image URLs.
    • Single-voice registers (migration 545) — kinds beyond summary/eli10/pitch_panel. Unlike presets, a new kind needs a migration (the about_that_renditions.kind CHECK from migration 493). Added tl_dr (ruthless 20-second / 50-80-word compression — busy-exec TL;DR) + devils_advocate (strongest honest objection, steelmanned, + the fair answer the page's own content supports — sales-page conversion) in 545; for_kids (ELI10 for a curious 6-year-old) + for_grandma (non-technical grandparent) + accessibility_companion (structure-first wayfinding for a listener who can't see the page — "3 sections: …") in 546; author_note (the author's own "why I wrote this" note behind a blog/Substack post, grounded, no invented biography) in 547. All opt-in: enabled_kinds default stays {summary,eli10,pitch_panel}. Prompt map SINGLE_VOICE_DIRECTIVES in about-that-core.ts is exhaustive over SingleVoiceKind (missing directive = compile error); summary/eli10 wording kept byte-identical. Render path (single-voice branch, feature_key about_that.render.<kind>) is kind-generic — no per-kind service change. Toggle in /dashboard/about-that KINDS. Golden-tested. Apply migration 545.
    • whats_new register — diff-aware "what changed since your last visit" (migration 555) — the one register whose output depends on more than the current page: it narrates the difference between the previous captured version of a page and the current one. New table about_that_page_versions (migration 555) is an append-on-change log of the clamped extracted text, one row per DISTINCT content per (embed_id, page_url). On render, resolveWhatsNewDiff in about-that.ts records the current version (upsert ignoreDuplicates) and looks up the latest stored version whose content differs from it — that's "what changed since last time". "Previous" is thus a stable function of the current page state, not of render history, so an unchanged page always diffs against the same prior version → a free cache hit, while a real edit re-renders. Cache key whatsNewCacheKey (in about-that-core.ts, pure + golden-tested) folds both prior + current text (prior via a short hash so the key stays fixed-length) atop renditionCacheKey, so voice/tone/preset/lang still participate. Prompt buildWhatsNewPrompt has two modes: first-sighting (no prior version — orients the listener to what the page is now + promises to report changes next time, never fabricates a history) and changed (feeds both versions, asks for ONLY meaningful differences, grounded strictly in the two texts — SINGLE_VOICE_DIRECTIVES.whats_new). resolveWhatsNewDiff is best-effort (a version-log DB hiccup degrades to a first-sighting render, never fails the rendition). Single-voice + gpt-4o-mini, feature_key about_that.script.whats_new. Opt-in (enabled_kinds default unchanged); toggle in the dashboard KINDS. Apply migration 555.
    • Multi-language renditions (migration 548) — "your site, in your voice, in Spanish." TTS is already eleven_multilingual_v2, so a language = the SCRIPT written in it (no new TTS model/path). Set ABOUT_THAT_LANGUAGES in about-that-core.ts (code-validated, no CHECK): en/es/fr/de/pt/it/ja/zh/hi. Per-embed enabled_languages (about_that_embeds, default {en}; normalizeLanguages always keeps en). lang is a cache-key dimension in renditionCacheKey but en folds in nothing (byte-identical → existing English audio never re-renders / re-keys); each non-en language caches independently (about_that_renditions.lang descriptive; correctness rides content-hash). Prompts append a "write in <lang>" directive only for non-en (en/founder prompts stay golden-locked byte-identical; pitch panel keeps English speaker tokens, translates text). resolveLang degrades an off-list/stale ?lang= to English (never 403). Config exposes languages only when >1; PlayerClient shows a <select> + honors ?lang=es; dashboard create-form + per-embed 🌍 editor (PATCH enabled_languages). Eager render pre-renders English only (lazy per-language on first listen) so N languages don't N× page-visit spend. Golden-tested. Apply migration 548.
    • Vertical preset skins (no migration)PRESET_CONFIG in about-that-core.ts re-skins the pitch_panel persona pair + player label per embed; validated in code against ABOUT_THAT_PRESETS (no DB CHECK, so a new vertical is a pure config add). Shipped set: founder (FOUNDER/INVESTOR — The Pitch Panel, default + golden-locked), candidate (CANDIDATE/RECRUITER — The Interview Panel), agent (AGENT/BUYER — The Open House Panel), launch (FOUNDER/HUNTER — Launch Day, for Product-Hunt / launch-day pages), restaurant (OWNER/GUEST — The Chef's Table), nonprofit (ADVOCATE/DONOR — The Giving Table), maker (MAKER/SHOPPER — The Maker's Bench). Each pair has distinct first letters (parsePanelScript coerces speakers by first-letter prefix) and is a cache-key dimension. Picker in /dashboard/about-that PRESETS. Golden-tested per-preset (label + grounded prompt + distinct cache key). Full table: docs/product/ABOUT_THAT.md.
    • Preset dimension (migration 514 adds about_that_tries.preset): the try wall now renders any preset, not just founder. Dedupe is per (url_normalized, preset) and the cap is per-preset (each preset gets its own 25) so a vertical wall can't exhaust the generic one. submitTry({url, ip, preset?}) + getTryGallery(preset?) + renderTryInBackground(…, preset) (voices the pitcher token per PRESET_CONFIG[preset].pitcher); GET/POST /api/about-that/try take ?preset=/{preset}. Real-estate "try your listing" (RealEstateTryClient.tsx on the for-real-estate page): an agent pastes a listing URL → hears its Open House Panel (agent preset) render in the house voice, with the signup hook "now hear it in YOUR voice, on every listing"; the agent-preset gallery below doubles as the live demo. The conversion wedge for the RE vertical. IDX/MLS fallback: submitTry accepts {text, title} as an alternative to url — JS-widget listings render no server-side text, so the agent pastes the details and it renders straight from those (synthetic paste:<hash> dedupe key, no OG image, same cap/moderation); a "Listing on an IDX/MLS widget? Paste its details" toggle in the client, auto-opened when a URL read fails (422 "…paste the listing details instead"). Checkout wired too: the dashboard startPlan('agent'|'brokerage') hits the correct /api/payment/about-that/checkout (the old /api/about-that/checkout was a 404), and the RE pricing cards get Start CTAs — activation still gated on the owner's Stripe prices + paid-mode flag. Apply migration 514.
  • Brokerage tier front door (migration 515 about_that_brokerage_leads): the $399 team tier's honest v1 while the seat model is built — a dedicated landing /about-that/for-real-estate/for-brokerages (team pitch: every agent in their own voice, one account) + an interest-capture form (BrokerageLeadForm.tsx) — no charge, no overpromise. POST /api/about-that/brokerage-lead (public, honeypot-guarded, upsert on email, submitBrokerageLead) + super-admin GET /brokerage-leads. Best-effort Slack notify on each new lead (notifyBrokerageLead, opt-in ABOUT_THAT_LEADS_SLACK = #channel/C…/email-to-DM, gated on slackEnabled(), fire-and-forget — mirrors notifyLighthouseSignup) so hands-on onboarding can respond fast. The RE page's Brokerage pricing card now routes here ("Talk to us"). Apply migration 515.
  • Brokerage seat model — Phase 1: team + bill-together (migration 516 brokerages + brokerage_members; plan ABOUT_THAT_BROKERAGE_SEAT_MODEL.md): a brokerage owner names the brokerage + invites agents by email (accept link, token-authed), an agent accepts (binds their account), and each active member's own agent-preset embed is covered by the brokerage's one about_that_brokerage subscription — the "bill-together" magic via isBrokerageCovered extending requiresAboutThatPlan (about-that-billing.ts; plan keys extracted to about-that-billing-plans.ts to break the billing↔brokerage cycle). Service about-that-brokerage.ts (setBrokerage/inviteMember/acceptInvite/removeMember/isBrokerageCovered) + pure core (hashInviteToken/normalizeEmail/normalizeBrokerageName, golden-tested). Routes under /api/about-that/brokerage (owner: GET/POST, /invite, DELETE /members/:id; agent: POST /accept). Dashboard /dashboard/about-that/brokerage (name/invite/members/remove) + accept page /about-that/brokerage/accept (useSearchParams in <Suspense>). Apply migration 516.
  • Brokerage seat model — Phase 2: one snippet, right agent (migration 517 brokerage_members.agent_slug): one brokerage embed voices each listing in the listing agent's own voice. data-agent="<slug>" on the listing page → about-that.js loader forwards it to the iframe player → PlayerClient sends agent in the rendition/config calls → getOrRenderRendition/eagerRenderForPage call resolveAgentVoice(embed.user_id, slug): resolveAgentUserId gates that the slug is an ACTIVE member of THIS embed owner's brokerage, then a consented-clone check — null → falls back to the embed's own voice (never voices a non-member or an unconsented clone; the whole security + consent gate is here). Per-agent agent_slug (email-localpart, per-brokerage unique via uniqueAgentSlug) shown on the brokerage dashboard with a data-agent hint. Agent-specific voiceKey → renditions cache per-agent automatically. Pure slugifyEmail golden-tested. Apply migration 517.
  • QR print kit (physical → listen bridge): puts About That listings on the sidewalk. A QR on a yard-sign rider / open-house sheet / business card → the existing hosted listen page /about-that/listen/[embedId]?url=&agent= (which already renders that listing's Open House Panel in the agent's voice — agent for the brokerage per-listing voice) → a driver scans and hears the home. Server-rendered print-ready page /about-that/print/[embedId]?url=&agent=&kind=&name= generates the QR (QRCode.toString SVG, server-side, mirrors the season press-kit) and lays out rider (yard-sign) / sheet (per-house open-house) / card (per-agent — point url at your bio) print-optimized (@page per kind + @media print; ⌘-P → PDF → the agent prints or hands the file to their sign vendor — no print-fulfillment op). Dashboard tool /dashboard/about-that/print-kit (pick embed → target URL + agent slug + name → copy the listen link + open each artifact). No migration; no backend. North-star (P3 task): glasses-captured first-person walkthrough → VR re-watch off the same QR (honest: Mentra Live = one forward camera → first-person video in a WebXR room, not 6DoF).
  • Vertical presets (migration 495, about_that_embeds.preset, default founder, NO db CHECK — code-validated against ABOUT_THAT_PRESETS): a preset re-skins the pitch_panel persona pair + player label per embed (kinds never change; summary/eli10 unaffected; the try wall renders any preset as of migration 514). PRESET_CONFIG in about-that-core.ts: founder FOUNDER/INVESTOR "The Pitch Panel" (byte-identical to pre-preset prompt — golden-locked), candidate CANDIDATE/RECRUITER "The Interview Panel" (resume/portfolio pitches the owner), agent AGENT/BUYER "The Open House Panel" (listing agent vs skeptical buyer; per-listing renders fall out of content-hash caching). Persona pairs must keep DISTINCT first letters (parsePanelScript coerces by first-letter prefix). Preset is a cache-key dimension. Vertical landings: /about-that/for-job-seekers, /about-that/for-real-estate (linked from the landing's "Built for" section — add a card there when a new for-* page ships). Limitation (stated on the real-estate page): extraction is plain fetch + readability — JS-only IDX listing widgets yield no text.
  • Eager (non-lazy) rendering (migration 496, about_that_embeds.eager_render default true): the player passes ?url= on its config fetch; the config route fire-and-forgets eagerRenderForPage (service) which renders every enabled kind for that page on VISIT instead of first listen. Two-switch throttle: global about_that_eager_render in CRON_REGISTRY (cron-toggles.ts — NOT a cron, no heartbeat; rides the registry for the /dashboard/admin/crons kill switch, OFF = platform-wide lazy in ~60s) AND per-embed eager_render (owner opt-out — renders bill the owner). All lazy-path guards reused verbatim via getOrRenderRendition (domain gate, daily cap, content-hash cache, tombstones) + the rendition IP throttle on the trigger. Env fallback: ABOUT_THAT_EAGER_RENDER.
  • Voice palette (one-click multi-embed mint): createVoicePalette(userId, {domain, preset, voices[]}) in about-that.ts loops createEmbed to mint N embeds at once — one per voice character — all gated to a single domain, each voice_mode:'narrator' with a distinct narrator_voice_id and eager_render:false (lazy, so several players on one page don't all render on load). Vertical-agnostic via preset (agent = realty agent roster, candidate = job-seeker cohort, founder = startup lineup — defaults to agent). Returns {id, name, label, narrator_voice_id}[] so the owner can hand a partner the label→embed_id map. POST /api/about-that/palette {domain, preset?, voices:[{label, narrator_voice_id}]} (creator-gated). UI: VoicePalette.tsx collapsible panel on the About That dashboard (vertical picker + domain + editable voice rows pre-filled per-vertical from /api/story-seasons/voice-bank; creates + shows a copyable label:id list). Built for the QuickSites realty-agency template (multiple realtors, each their own About That voice), reusable for future QS industry scaffolds + the brokerage seat model — coordinated via crosstalk; voices are generic narrator-bank presets (no consent surface).
  • faq_answer register — visitor asks, page answers (migration 498, about_that_embeds.faq_enabled default false + table about_that_faq_answers): the INTERACTIVE register — a visitor question answered STRICTLY from the page's extracted text (grounding is the product; the prompt emits a NOT_ON_PAGE sentinel → answerable:false graceful decline over invented hours/prices/policies). Opt-in capability flag (NOT an enabled_kinds member — it takes input) with its own public POST /api/about-that/embed/:id/ask {url, question} (200 answer / 202 answering+poll). Guards: domain gate, OpenAI moderation on the visitor question (isPageTextFlagged reused), SSRF fetch, per-embed daily cap (100) + tighter per-IP throttle (isFaqRateLimited, 20/IP/day, namespaced rehearsal_rate_limits), content-hash cache+claim keyed faqCacheKey(pageText + normalizedQuestion + voiceKey) (repeat asks free; page edit / voice change / rewording re-answers). Text-primary; audio best-effort in the embed voice (TTS failure never blocks the text). Pure core golden-tested: normalizeQuestion, faqCacheKey, buildFaqPrompt, parseFaqAnswer. UI: player shows the "ask" input only when config faq:true; needs height so it's on the hosted /listen page + a data-faq="1" loader opt-in (taller 250px iframe) — the 148px default embed stays chips-only. Dashboard: create-form checkbox + per-embed enable/disable. This is the stepping stone to the Site Concierge (crosstalk ideas.md §8): same grounding + owner-billed/domain-gated/capped model, minus multi-turn state. Feature keys about_that.faq.script / about_that.faq.render (billed to owner).
  • Voice Welcome (migration 507 about_that_welcomes; contract crosstalk/contracts/voice-welcome-endpoint.md): a fixed owner-written script rendered ONCE in the embed's resolved voice (clone-w/-consent or narrator) → a permanent public MP3 the QuickSites voice_welcome block plays with its own UI (no iframe). Owner-scoped POST /api/about-that/:id/welcome {script} (creator-gated) → { welcome_id, audio_url, cached }; renderWelcome reuses resolveVoice + ttsSegment (billed to owner) + uploadAudio, cached by welcomeCacheKey(script+voiceKey) (re-submit = free hit; edited script re-renders, old MP3 kept — baked into published sites, must never rot). Pure normalizeWelcomeScript (≤600) + welcomeCacheKey golden-tested. Dashboard: "Welcome audio" field per embed (write → Make it → preview + copy audio link). The default-audio mechanism for the §13 About-Me sites (narrator now → owner clone once recorded). Apply migration 507.
  • Testimonial-to-audio (migration 508 about_that_testimonials; contract crosstalk/contracts/testimonial-audio-endpoint.md): a third-party customer quote rendered ONCE to a permanent public MP3 the QuickSites testimonial_audio block reads aloud. Same render-once rail as Voice Welcome, with ONE binding difference — the ethical guardrail: a testimonial is someone else's words, so it renders in a NARRATOR voice ALWAYS, never the owner's clone (renderTestimonial calls resolveNarratorVoice, which ignores the embed's voice_mode even when it's own_clone — putting a customer's quote in the owner's cloned voice would be fabrication). Owner-scoped POST /api/about-that/:id/testimonial {quote} (creator-gated) → { testimonial_id, audio_url, cached }, feature_key: about_that.testimonial. Cache key testimonialCacheKey(quote+narratorVoiceKey) carries a distinct testimonial prefix so it can never collide with — or reuse — a welcome MP3 of identical text+voice (golden-tested alongside normalizeTestimonial ≤700). Dashboard: "Testimonial audio (read a review aloud)" field per embed, with the narrator-only rationale shown inline. Block copy must frame it as "read aloud / listen to this review", never as the reviewer or owner speaking. Batch: POST /api/about-that/:id/testimonials {quotes: string[]} (renderTestimonials, ≤20, creator-gated) renders a whole reviews section in one call → { results: [{quote, testimonial_id, audio_url, cached} | {quote, error}] }; each quote rides the same cached narrator rail (re-running a section only pays for new/changed quotes), and one bad quote is a per-entry error rather than a failed batch. Apply migration 508.
  • Play/completion analytics (migration 502, about_that_stats daily rollup — the retention hook + paid-tier justification): the player fires a best-effort POST /embed/:id/event {url,kind,event} beacon on play (once per rendition — resume-after-pause doesn't recount) + complete (audio ended); recordPlayEvent validates embed active + domain (a leaked id can't inflate stats for arbitrary URLs) then upsert-increments the day's (embed_id, page_url, rendition_kind, day) row. Rollup by design — no per-listener PII. Owner readout GET /:id/statsgetEmbedStats (totals + per-page plays/completion% over 30d); dashboard shows a "Listens & completion (30d)" section per embed. Owner-gated; the event beacon is public + best-effort (never blocks playback).
  • Voice picking + re-render: dashboard VoicePicker (create form + per-embed "change" editor) with audible previews — narrator chips from the existing public GET /api/story-seasons/voice-bank (VOICE_BANK + narrator w/ ElevenLabs CDN preview_url), own-clone preview from GET /api/lovio/voice (preview_audio_url); saves via the existing PATCH (voice_mode/narrator_voice_id). Owner POST /api/about-that/:id/rerender (creator-gated) → rerenderEmbed: replays distinct (page_url, kind) pairs (≤40) through getOrRenderRendition fire-and-forget — a voice change re-keys the cache (voiceKey is a hash dimension) so each replay is a fresh render in the new voice; unchanged voice = free cache hits; 429 (daily cap) breaks the loop.
  • Distribution beyond the snippet: hosted listen page /about-that/listen/[embedId]?url=… — indexable standalone player (link-in-bio/QR/email; reuses PlayerClient with its frame='page' prop; same public endpoints + domain gate, no new render path; Next-15 Promise params + Suspense around useSearchParams). Dashboard rendition cards: "Copy listen link" + "Download MP3" (cross-origin download attr is ignored → fetch→blob→objectURL, window.open fallback).
    • GitHub README badge: shields-style flat SVG ("▶ hear this page | About That") for dev-founder repos — aboutThatBadgeSvg() in about-that-core.ts (pure, golden-tested; deterministic Verdana-ish width estimator, XML-escaped, active=amber+play-glyph / inactive=muted "unavailable"). Public route GET /api/about-that/embed/:embedId/badge.svg in about-that.ts routes always 200s with an SVG (paused/unknown embed → "unavailable" variant, never a JSON 404 — GitHub's camo proxy wants an image); Cache-Control: s-maxage=3600, stale-while-revalidate. Brand-domain URL via a scoped next.config.js rewrite (/api/about-that/embed/:embedId/badge.svg → backend) — same reason as the podcast RSS rewrite (a public README must not show the Railway host); scoped to the single badge path so in-app player traffic still hits the backend directly. Dashboard shows a live badge preview (relative <img> src, resolves via the rewrite in dev+prod) + "Copy README badge" (absolute-URL Markdown; listen link's ?url= defaults to the embed's first allowed domain so it's copy-paste-ready). No migration.
  • Real-estate paywall (dark-launched): the agent/Open-House preset is the paid vertical ($79/mo agent, $399/mo brokerage); founder + candidate stay free. services/about-that-billing.ts: aboutThatPaidMode() (env NEXT_PUBLIC_ABOUT_THAT_PAID_MODE, default OFF = no gate anywhere), isAboutThatSubscriber (reads subscriptions plan_key about_that_agent/about_that_brokerage — reuses graphene rails, NO migration), requiresAboutThatPlan(embed), and the PURE unit-tested shouldGateRender({paidMode,preset,subscribed}). Enforced in about-that.ts: getOrRenderRendition + askAboutPage throw 402 for an unpaid agent embed; eagerRenderForPage skips silently. Checkout POST /api/payment/about-that/checkout {plan} + priceToPlanKey (existing /webhook activates); status GET /api/about-that/billing/status. Dashboard shows the upgrade CTA on agent embeds only when paid mode is on + unsubscribed; /about-that/for-real-estate carries the pricing block. Launch runbook: docs/setup-guides/ABOUT_THAT_PAID_LAUNCH.md (create 2 Stripe prices → env → flip flag; comp-a-user SQL for Ryan's 90-day deal).
  • Env: ABOUT_THAT_INVESTOR_VOICE_ID (stock Antoni fallback — the panelist voice for ALL presets in v1); ABOUT_THAT_EAGER_RENDER (eager-render toggle env fallback); STRIPE_PRICE_ABOUT_THAT_AGENT/_BROKERAGE + NEXT_PUBLIC_ABOUT_THAT_PAID_MODE (real-estate paywall).

Public Routes

  • /featuresfeatures/page.tsx
  • /comparecompare/page.tsx
  • /compare/journaling, /compare/hivejournal-vs-*, /best-journaling-apps-2026 — see "Journaling comparison SEO cluster" above
  • /about/jqabout/jq/page.tsx
  • /jq-bridgejq-bridge/page.tsx
  • /jq-extensionjq-extension/page.tsx
  • /open-energy (legacy), /open-energy/experiments, /open-energy/contributors
  • /dreampro and the citizen-science platform pages — see DreamPro Citizen Science Platform above for the full list (/dreampro/map, /dreampro/competition/[slug], /dreampro/sponsors, /dreampro/sponsorship, /dreampro/sponsors/apply, /dreampro/opportunities, /dreampro/opportunities/[slug], /dreampro/sponsorable, /dreampro/meetups, /dreampro/meetups/[id], /dreampro/meetup-organizer, /dreampro/classroom, /dreampro/classrooms/[id], /dreampro/templates, /dreampro/templates/[id], /dreampro/templates/classroom, /dreampro/family, /dreampro/citizen-scientist)
  • /blog and /blog/[slug]blog/page.tsx — blog index + dynamic article pages
  • /learn and /learn/[slug]learn/page.tsx — educational content pages
  • /visionvision/page.tsx — company/product vision page
  • /hum, /hum/prototype — The Hum concept pages (see above)
  • /stories/[id] — public Odessa story share page (see Odessa section above)
  • /terms, /privacy — legal pages

Error & 404

  • App-router 404: apps/frontend/src/app/not-found.tsx
  • Pages-router error fallback: apps/frontend/src/pages/_error.tsx (exists to avoid build-time hydration crash)

See also

  • ARCHITECTURE.md — how the monorepo, deployment, and data flow are organized

  • CONVENTIONS.md — naming, file placement, when to add a migration

  • features/open-energy.md — deep dive on the original 10-phase Open Energy initiative

  • features/dreampro-citizen-science.md — deep dive on the unified DreamPro Citizen Science Platform (Phases 1–8)

  • Per-wall member visibility (migration 574 adds family_wall_displays.shown_member_ids uuid[]): each wall picks which family members appear via card selectors on /dashboard/family/wall (the "Who appears" section). null/empty = everyone (back-compat); resolveWallView filters rollup.members to the set. Saved through updateWallDisplay.

  • Screen time — app registry + allow windows (migration 576: family_apps + family_allow_windows): parents register the apps their family uses and set per-member rules — block outright, restrict to time-of-day windows / days of week, and a daily cap (daily cap ENFORCED via a usage log — migration 578). Service: family-screentime.ts with a pure, unit-tested decideAccess evaluator (default-open; blocked / outside-hours / wrong-day). Routes under /api/family/screentime/* (apps, windows, check?member=&app=). UI: /dashboard/family/screentime. The foundational layer the wall embeds (Spotify/Netflix) + per-child JQ access build on.

  • Kid-safe app launcher (migration 577 adds family_apps.og_image_url): /dashboard/family/launcher — no address bar / no search, just a grid of the approved apps shown as their OG images (fetched via getLinkPreview, emoji fallback). Each tile respects the screentime allow-window rules — blocked / outside-hours / wrong-day tiles render locked and won't open. GET /api/family/screentime/launcher?member= builds the per-member payload; re-evaluates every 60s so windows lock/unlock live. Point a kid's device at it + Add-to-Home-Screen. Enforcement surface #1 for [[the screentime registry]].

  • Screen time daily-cap usage log (migration 578: family_app_usage, one row per member/app/local-day): makes the daily cap real. recordUsage accumulates minutes; evaluateAppAccess sums today's usage vs the member's cap and returns cap-reached (app-specific cap → that app's usage; member-wide → total). Pure capStatus helper is unit-tested. Wired: each allowed JQ turn (wall Ask-JQ + kid-chat) records ~1 min; POST /api/family/screentime/usage for launcher/embed heartbeats.

  • Wall PIN device gate (migration 580: family_wall_security): when the owner sets a PIN, a device must enter it ONCE before it can display any wall — then a signed device token (bound to pin_version) is remembered until the PIN is changed/cleared, which bumps the version and re-locks every device. Service family-wall-pin.ts (HMAC-JWT_SECRET pin hash + device token, constant-time compare). resolveWallView(..., {deviceToken, enforcePin}) returns {locked:'pin'} → the wall shows a PIN screen; POST /wall/view/:token/unlock issues the device token; owner sets it via POST /api/family/wall/pin (+ /pin/status). A set PIN is also the trusted-device gate that will unlock calendar integrations. Wall UI: PIN screen on /family/wall/[token]; control on the wall settings page.

  • Unsplash backgrounds + Ken Burns screensaver (migration 581 adds family_wall_displays.bg_source+bg_tags; screensaver gains ken_burns): per-device rotating Unsplash imagery. family-unsplash.ts unsplashPhotos(query) (needs UNSPLASH_ACCESS_KEY; 30-min cache; hotlinks + attribution + download-event per Unsplash terms; degrades to []). resolveWallView fetches wallpapers when bg_source='unsplash' OR screensaver=ken_burns and passes them into the WallView (bg_source,wallpapers). Wall render rotates every 30s (bg crossfade or Ken Burns pan/zoom wallKenBurns keyframes) with photographer credit. Settings: a 'Rotating photos (Unsplash)' toggle + tags input + '🌲 Outdoor scenes in your area' preset (local: prefix → backend expands with the wall's weather location via resolveWallpaperQuery). Apply migration 581 + set UNSPLASH_ACCESS_KEY.

  • Family growth nudges (migration 582 adds family_members.growth_nudges text[]): parent-enabled gentle activity invitations a child's JQ avatar offers on their launcher. Catalog + kid copy in family-growth.ts (GROWTH_NUDGES: story/EmberKiln, graphene-kids, citizen-science, workout-kids, dreampro-step); resolveGrowthNudges rotates ≤2/day (deterministic, no coercion). Toggled per child on the child-view hub (/dashboard/family/preview/[memberId]); delivered as "JQ says…" cards on the launcher (GET /screentime/launcher now returns {tiles, nudges}). Guardrails: invitation-not-nag, parent-authored + parent-visible.

INDEX — Docs | HiveJournal