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.
Quick Links by Category
- Core Journaling
- Tracking & Wellness
- AI & Insights
- Social & Connections
- Gamification & Currency
- Goals & Activity
- Tasks & Calendar
- JQ Routines
- Life Map
- Customization
- Open Energy (legacy)
- DreamPro Citizen Science Platform
- Integrations
- Admin & Operations
- Public & Marketing Pages
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.tsxexports<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 onPUT /api/journal/:id({ notebook_id }/{ tags }), patch the detail page's localentrystate, andinvalidateDashboardEntryCache(). 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(migration382_journal_entry_merge_group.sql, partial-indexed) — entries sharing a non-null group id are one merged note. Ordering within a group is bycreated_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/mergeunlinks one entry and dissolves a now-orphaned group of one;GET /api/journal/:idhydratesentry.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.sql—journal_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.ts—createAttachment()(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 endpointGET /api/journal/share/:shareIdnow also returnsfirst_attachment: { url, thumbnail_url, mime_type, ... } | null. - Frontend component:
apps/frontend/src/components/entries/EntryAttachments.tsx— declarativecanEditprop 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 justMAX_ATTACHMENTS_PER_ENTRY = N; Phase 3 (PDF/doc) appends toALLOWED_MIME_TYPES+ a non-image render path in the gallery component.
Link previews (URL unfurl cards)
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.ts—getLinkPreview(url)fetches the page's<meta>OG tags (regex over the first ~256KB, no HTML-parser dep) and caches the result inlink_previews(migration 416, keyed by URL, 30-day TTL; cachesempty/errortoo so failures don't re-hammer). SSRF guardassertPublicUrl: 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 coresipIsBlocked+extractMetaare 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 }; returnsnull(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 insettings/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 ElevenLabsvoice_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 onunlock_atandunlock_milestone_datemake 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 byposition.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.ts — joinLovioWaitlist, 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 onlyGET /capsules/POST /capsules/GET /capsules/:id/DELETE /capsules/:id— owner-only CRUDPOST /capsules/:id/entries/DELETE /capsules/:id/entries/:entryId— add/remove entries; rejects when sealedPUT /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 enforcesminof "now + 1h" client-side; backend re-validates. Form posts toPOST /api/lovio/capsulesand 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 fetchesGET /api/journal?limit=30and 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.tsxwithbadge: 'New'.
Voice cloning (shipped):
- Service:
apps/backend/src/services/lovio-voice.tswraps ElevenLabs Instant Voice Cloning.createVoiceClone({ userId, consentBuffer, sampleBuffer, ... })orchestrates: (1)POST https://api.elevenlabs.io/v1/voices/addwith a multipart form containing the sample audio → returnsvoice_id; (2) insertlovio_user_voicesrow with the new id; (3) upload both consent + sample audio to the sharedseason-assetsstorage bucket atlovio/<user_id>/voices/<row_id>/{consent,sample}.<ext>and patch the row with public URLs; (4) best-effort preview render — callPOST /v1/text-to-speech/<voice_id>witheleven_multilingual_v2, upload the resulting MP3 aspreview.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) — acceptsconsent+sampleaudio files (≤25MB each via multer memoryStorage) + optionalvoice_label; callscreateVoiceClonePOST /voice/retire(auth) — soft-retires the active clone
- Frontend:
/dashboard/lovio/voice-setup— two-step browser MediaRecorder UI. CustomuseAudioRecorderhook handlesgetUserMedia+ permission errors + per-recording duration timer + mime-type auto-pick (audio/webm;codecs=opus→audio/webm→audio/mp4). FormData upload via the existingapiRequesthelper (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/lovioshows 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.tsgetLovioAdminOverview(). Pullscount: 'exact', head: truequeries 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 interleavedrecent_activityarray (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 existingrequireSuperAdminmiddleware inroutes/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— addsvoice_audio_url,voice_audio_rendered_at,voice_audio_voice_id,voice_audio_content_hashtojournal_entriesso 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 unlessforce), otherwise callsttsSegmentfrom season-audio, uploads tolovio/<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— probePOST /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— addsjournal_entries.voice_stream_enabled BOOLEAN NOT NULL DEFAULT FALSE+ a partial indexjournal_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 existingvoice_audio_url(400No recording exists for this entry yetotherwise) and also forces the entry'scontent_visibility='public'(audio implies public text). Disabling only clears the flag; content_visibility is left as-is. Returns{ voice_stream_enabled, content_visibility }. Lives inroutes/journal.tsnext to thePUT /:idupdate route. getEntryVoiceStatusnow also returnsvoice_stream_enabledso the entry-page toggle knows its initial state. The/api/journallist route surfacesvoice_stream_enabled(metadata-level) +voice_audio_url(content-gated) viafilterEntryForViewerindatabase/visibility.tsso 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.tsxrenders 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 1for consent v2: the clone consent string (lovio-voice.tsLOVIO_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 (throwsNeedsConsentError/NoVoiceError), cache-lookup by text hash, elsettsSegment(quota-gated viauser_idcontext) →uploadAudio('jq-voice/<userId>', '<hash>.mp3')→ upsert cache.getDreamNudge(userId)composes one short sentence from the first active dream +getNextStepviacomplete({ model_key: 'claude-haiku-4-5' }), cached injq_dream_nudges. - Routes (auth-required):
routes/jq-voice.tsmounted at/api/jq/voice—POST /say { text }(≤600 chars) andGET /nudge?occasion=(204 when no active dream). Both return 409NEEDS_CONSENT/NO_VOICE→ frontend routes to/dashboard/lovio/voice-setup. - Cadence (migration 459):
getDreamNudge(userId, occasion)supportsmorning | 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 inlib/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 afterhandleCompleteStep. at_risk is wired via<StreakNudgeTrigger>(components/jq/StreakNudgeTrigger.tsx) on/dashboard: on load it checksGET /api/journal/streakand, when a streak is going buthas_todayis 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/nudgecomposes 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)inservices/jq-voice.tsreadsgetLatestDeliveredEncouragement()(coach-brief.ts) → condenses the ≤120-word coach note into one short sentence (claude-haiku-4-5) → caches per(user, decision, occasion)injq_coach_nudgesso a given delivered decision always yields the same words → samejq_voice_clipsaudio row (no ElevenLabs re-spend). The route prefers the coaching nudge, falls back togetDreamNudge, and stampssource: '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 normalizersanitizeSpokenLinelives in the dependency-freeservices/jq-voice-text.ts(golden testtests/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 thejq:speakingwindow event; the global JQ orb (Chatbot.tsx) listens and swaps its idle purple pulse for a faster amberpulseSpeakingwhile 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-presence→getPersonaPresence()picks a recentpersona_scrapbook_entriesrow 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 ambientsubject:'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 actionablesubject:'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) — currentlyhome,ethos,features,30-day-reset,compare,about-jq,lovio,pricing,odessa,emberkiln. Cache keycontent_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.tsat/api/narration: publicPOST /hear { surface }; super-adminGET /admin/surfaces,POST /admin/rerender-all,POST /admin/rerender { surface }. - Frontend:
<HearAboutThis surface=… className?=… />(components/narration/HearAboutThis.tsx) — never autoplays, pulses thejq:speakingorb while playing; placed on/,/ethos,/features,/30-day-reset,/compare,/about/jq,/lovio,/pricing,/odessa,/emberkiln(per-page palette viaclassName). 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 = aSURFACE_REGISTRYseed + 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).runSdkReleaseWatchTickresolves each source to one value (version / release tag /page_hash), compares to the newestsdk_release_alertsrow per key: first sighting →baseline(no alert); changed →newrow + aservice_errorswarning (so/errorscatches it too). Per-source failures isolated + throttled.listSdkWatch/setSdkAlertStatusback the admin view. - Cron
sdk_release_watch_tick(24h) wired inindex.ts; registry keysdk_release_watch(cron-toggles.ts,maintenance, on by default, $0) +CRON_LABELSinsystem-health.ts. - Route
routes/sdk-watch.tsat/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 toTRACKED_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 MentraOSAppServer(@mentra/sdk, TypeScript, deploys to Railway). Bootstrapsrc/index.ts→src/session.ts(attachJqSession,speakNudge). DreamPro speak-path: button/voice →GET /api/jq/voice/nudge(cached MP3) →session.audio.playAudioin-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 GPLv3cicero-sdkis deliberately avoided;SOPHIA_ENABLEDoff by default, fails safe). Single-user auth = oneHIVEJOURNAL_TOKEN(a durable device token, below). Tests:npm test(node --test via tsx), mock-session harness — no hardware. - Durable device token (
migration 511glasses_tokens): the long-livedHIVEJOURNAL_TOKENfor the glasses server, replacing the ~1h Supabase JWT that would 401 a persistent deploy within the hour. Formathjg_<hex>;authenticateUserprefix-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). PurehashGlassesToken+isGlassesTokenFormatgolden-tested (glasses-tokens-core.ts). Dashboard: "🔑 Connect this device" card on/dashboard/glasses. Apply migration 511. - Multi-user device linking (
migration 513glasses_device_links; plan GLASSES_MULTIUSER_AUTH.md): binds a MentraOSmentra_user_idto 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 vialib/glasses-session-token.ts, stateless, 24h, prefix-routed byauthenticateUser) — 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). PurenormalizeLinkCode/hashLinkCode/formatLinkCode+ the session-token round-trip golden-tested. Glasses runtime (built):onSessionresolves the wearer whenGLASSES_APP_SECRETis set; the per-session token rides anAsyncLocalStoragecontext (hivejournal.tsrunWithSessionToken, wrapping the handler bodies insession.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 + setGLASSES_APP_SECRET(same value on the glasses server). - Backend
routes/glasses.tsat/api/glasses:GET /status(super-admin —{ voice, hasActiveDream, lastSession };voice/hasActiveDreamviagetJqVoiceReadiness()inservices/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()inapps/glasses/src/hivejournal.ts(fire-and-forget, never disrupts a session) →POST /api/glasses/events→ migration491_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+createWatcherdebounce), wired behindWATCH_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
createPostureMonitorstate machine (fully unit-tested insrc/posture.test.ts— calibration/sustain/cooldown/recovery/hysteresis, no SDK, no clock of its own) + a single SDK seamgetHeadPitchStreamthat 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 behindPOSTURE_ENABLEDinsrc/session.ts; cues logged best-effort askind:'posture'on/dashboard/glasses. Camera-based pitch fallback (no IMU needed) — WIRED end-to-end —src/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.tscheckPostureFromCamera) → spoken tilt, loggedkind:'posture'(source:'camera'). Gated onPOSTURE_ENABLED+ backend key; user-triggered only. Migration 595 widensglasses_spoken_events.kindCHECK to includecairn+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_ENABLEDon top ofPOSTURE_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 missingcairn+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, likedownstream_*):lodestone_anchors(place + geofence:lat/lng/radius_mdefault 75,accessCHECK-locked to'family',status),lodestone_payloads(kindCHECK-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/kindenums 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— purehaversineMeters+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-audiouploadAudiobucket — no new storage),listAnchorsForUser(owned + family-shared),nearbyForUser(family-visible anchors whose fence contains the caller),recordVisit(idempotent upsert).resolveAudioTypeaccepts 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 reusesresolveFamilyOwnerId(family-access.ts, migration 075) — whole-group in Phase 0 (per-recipient scoping deferred). - Audio transcode (Phase 1a):
services/lodestone-audio.ts—transcodeToMp3(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) beforeuploadStoryAudio— 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/lovioinindex.ts), allauthenticateUser, catches →recordServiceError:POST /anchors(create + optionally attach a story — multipartaudioclip via multer, 25 MB cap; any browser recording / voice memo, non-MP3 transcoded to MP3 server-side; or anaudio_url/transcriptin 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; serverpage.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.geolocationwith 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 firesPOST /anchors/:id/visit). UsesapiRequest(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/sdkexposes device GPS viasession.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=inroutes/glasses.ts→ the single NEAREST in-range playable story + a ready-to-speakintroline +place_label/transcript. ReusesnearbyForUser(family scope + server-verified geofence) vianearestCairnStory(services/lodestone.ts), which sorts byhaversineMetersand 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/glassesroute). A'cairn'kind was added to theglasses_spoken_eventslog (migration 491). - Glasses app
apps/glasses/src/cairn.ts: location provider seamgetLocationStream(session, onFix)wraps the SDK stream but aCAIRN_SIM_LAT/CAIRN_SIM_LNGenv emits one fixed fix instead — so the whole poll → speak → play loop is desk/CI-testable without hardware.playCairnOnArrivalresolves the nearest memory, dedupes by anchor id (marked before the awaits so a re-entrant fix can't double-fire),session.audio.speak(intro)thenplayAudio(audio_url), and logs a'cairn'event.attachCairnwires it ontosession.location(in-flight guard, unsubscribe returned); called fromattachJqSession(session.ts) behindcairnEnabled()(opt-in:CAIRN_ENABLED=1or a sim coord) + a resolvable-wearer guard, unsubscribed ononDisconnected. Event-driven off the location stream — no polling cadence, so no cron/heartbeat (the per-sessionglasses_sessioncheck-in already heartbeats). Client fetchfetchNearbyCairn+CairnStoryinhivejournal.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.
- Backend wrapper:
- 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
contextjsonb (SecondSet →{work_order_id,…}, AisleAsk →{store_id,…}, HJ →{entry_id}, Cairn →{anchor_id}) — the rail stores + returns it verbatim. Add a consumer = widen thepurposeCHECK, no other schema change. No per-consumer columns. - Migration
561_captures.sql— onecapturestable, RLS on / no public policies (service-role only, likedownstream_*/lodestone_*):purposeCHECK-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,statuspending|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 namedcaptures— neither is automatic. - Service
services/captures.ts:createCapture(validates via the purevalidateCaptureInput, then mirrorsjournal-entry-attachments.tscreateAttachment— uploads the original to the privatecapturesbucket at<userId>/<captureId>.<ext>, generates a 400px webp thumbnail viasharp, ~1h signed URLs; does NOT callcreateAttachmentdirectly because that's entry-scoped),signCapture(~1hcreateSignedUrlfor both paths),listCapturesForUser(owner-scoped,purpose/since/status/limitfilters, 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_idANDpurpose === 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 theconsentjsonb): aconsent.subject === 'customer'capture (SecondSet — recording in someone's vehicle/space) REQUIRESconsent.obtained === trueor 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 spokennoteis now stored, only under a setpurpose+ consent. - Routes
routes/captures.ts, mounted at/api/captures(next to/api/lodestoneinindex.ts), catches →recordServiceError(service'captures'):POST /(owner-onlyauthenticateUser; multermemoryStorage, 10 MB / 1 file, fieldimage— optional; fieldspurpose/note/lat/lng/accuracy_m/captured_at+context/consentas JSON strings;wearer_user_idderived from the token, NEVER the body),GET /?purpose=&since=&status=&limit=andPOST /:id/ack(dual-auth viaownerOrCapturePartner, 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/hashGrantTokenimported fromservices/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— tablecapture_partner_grants(sibling ofabout_that_partner_grants), RLS on / no public policies:partner,owner_user_id→auth.users,purpose(same CHECK set ascaptures),token_hashUNIQUE (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'smintGrant);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 path —
middleware/captures-partner.tsownerOrCapturePartner(mirrorsabout-that-partner.tsownerOrPartner): whenX-Partner-Idis present it takes the partner path (resolve grant → scope togrant.owner_user_id+grant.purpose→touchCaptureGrant); else falls through toauthenticateUser. Header contract:X-Partner-Id(partner slugquicksites, presence selects the path),X-Partner-Key(shared secret, envPARTNER_QUICKSITES_SECRET),X-Partner-Grant(owner-minted raw grant token). On the partner path the querypurposeis ignored — the grant's purpose is authoritative. QS matches a pulled capture to a job bycontext.job_id, thenPOST /: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.
- Migration
- Golden test
tests/captures.test.ts— locks the deterministic core:purposeallow-list, the consent gate (customer-without-obtained throws; self/none pass), empty-capture rejection, coordinate validation. supabase + sharp mocked viajest.unstable_mockModuleso it runs without a bucket. 21 tests. - AisleAsk scan → emit an
aisleask_planogramcapture 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
capturesbucket,PARTNER_QUICKSITES_SECRETset, 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/bindingis dual-auth: no partner header → the tech's ownactiveBinding(unchanged);X-Partner-Idpresent → 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 HJuser_id(the same id QS passes astarget_user_idto/voice-notes), scoped to the partner's own bindings, no other fields exposed. Lets QuickSites learn a tech's ref on first bind (→ theirservice_shop_techsroster) with no onboarding UX. No migration (reusesglasses_job_bindings). - Migration
563_glasses_secondset.sql—glasses_job_bindings(partial-unique(wearer_user_id) WHERE active→ exactly one active binding per wearer;capture_token,job_id==work_order_id,shop_id,partnerdefaultquicksites) +glasses_voice_notes(target_user_id→auth.users,audio_url,text,statuspending|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(bytarget_user_id, or byjob_idvia 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; elseVoiceNoteConsentError403 → no unsolicited audio),pendingVoiceNotes/markVoiceNotePlayed. Atext-only note is TTS'd through the house voice viastudio-narrate.tsnarrateLine(cached by content hash — no ElevenLabs re-spend). QR decode lives inservices/glasses-qr.ts(sharp→ raw RGBA →jsqr; new backend depjsqr) — 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 withX-Partner-*):POST /api/glasses/voice-notes{ target_user_id? | job_id, text? | audio_url? }→ resolve target → enqueue (binding-exists = consent gate;source_partnerfromX-Partner-Id);GET /api/glasses/voice-notes/pending(tech-authed),POST /api/glasses/voice-notes/:id/ack(tech-authed).
- Binding — the TECH authenticates (
- Glasses (
apps/glasses/src/secondset.ts, INERT unlessSECONDSET_ENABLED):bindJobFromScan("scan job" voice intent viaintent.tssecondset_bind→requestPhoto→POST /bind/scan),activeBindingContext(cached 60s — the capture path incapture.tstags each shot with the binding context),attachVoiceNotePoll(session-scopedsetIntervaldraining pending notes →playAudio→ ack, deduped by id; wired insession.ts, torn down on disconnect). This poll is a client-side loop in the glasses AppServer, not a backend cron — therecordHeartbeat/CRON_REGISTRYmachinery is backend-only (supabase service role) and unavailable inapps/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). Glassessecondset.test.ts— 8 node:test cases (enable gate, bind-command routing,bind/scanpost, 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_tokenmint. HJ builds thePOST /bindendpoint the companion calls, not the companion UI.
Lovio flag UI surfaced everywhere (shipped):
- The
journal_entries.lovio_flagged_atcolumn +PUT /api/lovio/entries/:id/flag+GET /api/lovio/flagged-entriesroute 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) —openPickernow fetches/api/journal?limit=30AND/api/lovio/flagged-entriesin 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 thelovio-flag-toggledwindow 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
/dashboardjournal cards (handleToggleLovioFlaginapps/frontend/src/app/dashboard/page.tsx). Both buttons dispatch their own*-toggledwindow 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_tokenlets 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)insidegetCapsuleByOwnerForPreviewinlovio-delivery.ts) returns the sameRecipientCapsuleViewshape. Frontend page/dashboard/lovio/[id]/previewrenders the same UI the public unlock page renders. Differs deliberately: auth-gated (fetches client-side viaapiRequest), works without adelivery_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 NULLat 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_atis stamped in both cases so the cron is idempotent. - Backend service:
manualResendDelivery({ capsuleId, userId, mode })inlovio-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 stampsdelivery_email_sent_atwhen 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.tsexportsrunLovioDeliveryTick()+getCapsuleByDeliveryToken(token)+markCapsuleOpened(token).runLovioDeliveryTick()finds sealed + not yet unlocked + not revoked + date-trigger capsules whereunlock_at <= now()ANDdelivery_email_sent_at IS NULL. Hydrates sender display names from profiles. Sends one styled email per capsule torecipient_emailvia Resend with a CTA to${SITE_URL}/lovio/unlock/<delivery_token>. Stampsdelivery_email_sent_aton 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 fromlovio_capsule_entries(materialized at seal). Token validation is a length check; the lookup itself is the actual gate.markCapsuleOpened(token)atomic update withWHERE unlocked_at IS NULLso it's idempotent —unlocked_atis 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 revokedPOST /unlock/:token/opened— idempotent stamp ofunlocked_at
- Cron: hourly tick in
index.ts, 250s boot delay, heartbeat-tracked aslovio_delivery_tick. CRON_LABELS updated. - Frontend:
/lovio/unlock/[token]/page.tsxis 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).UnlockClientfires a non-blocking POST to/openedon mount sounlocked_atis 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— addslovio_capsule_reminderslog table +profiles.lovio_reminders_opted_out_atopt-out column. - Opt-out toggle (settings UI shipped):
LovioPreferencesSectionon/dashboard/settingsrenders a single toggle backed byGET /api/lovio/preferences+PUT /api/lovio/preferences(inroutes/lovio.ts). PUT writes a timestamp toprofiles.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.tsrunLovioReminderTick()— finds sealed, not-yet-unlocked, not-revoked, date-trigger capsules whereunlock_atis >30 days out; groups by user; joinsprofilesfor email + opt-out check; looks uplovio_capsule_remindersrows 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 withsend_errorpopulated. - Cron: 24h tick wired in
index.ts, heartbeat-tracked aslovio_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.tssealCapsule()builds the narration script, atomically claimssealed_at(compare-and-swap so a double-click can't double-charge ElevenLabs), renders in the owner's cloned voice, uploads tolovio/<user_id>/capsules/<id>.mp3, mintsdelivery_token; rollssealed_atback 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 viattsSegment(taggedfeature_key: 'lovio_seal'for spend attribution; nouser_idso seals aren't quota-gated) and the MP3s are stitched withconcatMp3Buffers(ffmpeg stream-copy concat — clean because every chunk is the same voice/model/settings; a single-chunk capsule passes through untouched). Overall ceiling isLOVIO_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.tslocks the chunker (never over cap, lossless, no empty chunks). - Crosstalk live viewer (
migration 499crosstalk_messages): super-admin page/dashboard/admin/crosstalkrendering the HJ↔QuickSites Claude-session collaboration thread (the_SilverLamp/crosstalk/file mailbox) in near-realtime (pollsGET /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 viascripts/crosstalk-sync.ts(idempotent upsert by filename;acked=in archive/), auto-fired best-effort bycrosstalk/bin/crosstalkafter 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.parseCrosstalkFileis the pure frontmatter parser. - Cron kill switches: the three lovio crons (
lovio_delivery_tick,lovio_reminder_tick,lovio_waitlist_drip_tick) are now registered inCRON_REGISTRY(services/cron-toggles.ts,defaultEnabled: trueto preserve current behavior) with a top-of-tickif (!(await isCronEnabled(...))) returnguard inindex.ts. Super-admin can now pause any of them in ~60s at/dashboard/admin/crons— critically the delivery cron, which emails REAL recipients — withoutDISABLE_CRONS(which kills every cron) + a Railway redeploy. All three remain no-ops withoutRESEND_API_KEY. - Readiness preflight:
GET /api/lovio/admin/readiness(super-admin, inroutes/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 }—readyANDs 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.sqladdslovio_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 vialovio_capsule_entries— owner hand-picks them via the entry-picker drawer - Notebook-bound (
source_notebook_idset): the bound notebook IS the entry list.listCapsuleEntriesreadsjournal_entries WHERE notebook_id = source_notebook_idlive until sealed; new entries written into the notebook auto-join the capsule
- Manual (
addEntryToCapsule+removeEntryFromCapsulereject mutations on notebook-bound capsules ("write the entry into the notebook instead").sealCapsule(inlovio-seal.ts) materializes the notebook entries intolovio_capsule_entriesat 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/loviohas a two-button source-mode picker ("📝 Pick entries by hand" / "📓 Dedicate a whole notebook"). When notebook mode is picked, a select populates fromGET /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, callsttsSegment(script, voice_id, 'default')reusing the same TTS helper as season audio, uploads the resulting MP3 tolovio/<user_id>/capsules/<capsule_id>.mp3, mints acrypto.randomBytes(24).toString('base64url')delivery token, and patches the capsule row withsealed_at,voice_id,voice_label,audio_url,audio_rendered_at,delivery_token.revokeCapsuleis 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 viaGET /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=MMinapps/backend/src/routes/journal.ts. Returns{ year, month, days: [{ day, count, dominant_mood }] }wheredominant_moodis 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_atover the month window, then in-memory bucket bygetUTCDate(). - Frontend:
apps/frontend/src/app/dashboard/calendar/page.tsx. Monday-first 7-col grid (leading-blanks padding via(getUTCDay() + 6) % 7). Day cells:MOOD_BGmap (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=100and 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— addsprofiles.weekly_recap_email_opted_in_at+weekly_recap_email_last_sent_atplus a partial index over(last_sent_at NULLS FIRST) WHERE opted_in_at IS NOT NULL. - Service:
services/weekly-recap-email.tsrunWeeklyRecapEmailTick(). Gates: Sunday only (getUTCDay() === 0), inside the 09-23 UTC waking window, per-user 6-day cap vialast_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 asweekly_recap_email_tick. CRON_LABELS updated. - Routes (auth):
GET /api/user/weekly-recap-preferencesreturns{opted_in, opted_in_at};PUTaccepts{opted_in: boolean}and stamps the timestamp or nulls it. - Settings UI:
WeeklyRecapPreferencesSectionon/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— addsprofiles.daily_prompt_email_opted_in_at+daily_prompt_email_last_sent_atplus a partial index over(daily_prompt_email_last_sent_at NULLS FIRST) WHERE daily_prompt_email_opted_in_at IS NOT NULLso the cron's "who needs sending today" lookup scans only the opted-in subset. - Service:
services/daily-prompt-email.tsrunDailyPromptEmailTick(). 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 asdaily_prompt_email_tick. CRON_LABELS updated. - Routes (auth):
GET /api/user/daily-prompt-preferencesreturns{opted_in, opted_in_at};PUT /api/user/daily-prompt-preferencesaccepts{opted_in: boolean}and writes a timestamp or null. - Settings UI: new "Daily nudges" card on
/dashboard/settingswith 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_atordelivery_email_sent_atlanded 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_earningscredited 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-statsinroutes/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 fromMOOD_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. Readshostheader, picks brand via the same normalization aslayout.tsx(replace /^www\./+ split by:). Each brand carriesname,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. Returnsapplication/manifest+jsonwithcache-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 existingthemeColor: '#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_milestonesso 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 && !hasActiveFiltersrule 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 viaPromise.alland 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
themedActionsuseMemo (so the user sees what the click will do). Dispatch keeps the palette open after flip so the swap is verifiable. Wired through the existinguseTheme()hook from@/contexts/ThemeContext. - Empty-results "write new entry" fallback: when
filtered.length === 0AND 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,Entergoes,Esccloses,Cmd/Ctrl + Ktoggles. 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⌘Kbutton so touch users (no keyboard) get a path in. - Searchable tags + super-admin-gated admin entries (#419): the
Resultinterface gained optionaltags?: 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 viauseIsSuperAdmin()from@/lib/useAdminStatusso non-admins never see them in the palette. Gating happens invisiblePages = 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:openwindow event contract as the palette so a navbar?button (sits next to the⌘Kbutton) 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 withenv(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.commark in the bottom row. - Two actions: Download (anchor to
canvas.toDataURL('image/png')) and Copy image (Clipboard APIClipboardItem({'image/png': blob}), falls back to "use Download" when unsupported). - Mounted from a "Share" button on each highlight card in both
/dashboard/highlightsand the per-entryEntryHighlightsSection. Modal lives in acreatePortal(..., 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-dayinroutes/journal.ts— selects all the user's highlights ordered by creation, computesdoy % rows.lengthto pick one, hydrates the source-entry title in a single lookup. Returns 404 when the user has no highlights. - Frontend:
HighlightOfDayWidget.tsxrenders 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_idisON DELETE SET NULLso highlights outlive their source entry;text_snapshotis a copy of the text at save-time so the captured insight survives later entry edits.coloris 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), hydratesentry_titlein 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:
EntryHighlightsSectionmounted 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/highlightswall view — 2-up grid of colored cards with source-entry link + relative-time stamp + delete affordance.
- Nav: "Highlights" entry added under Journal in
FeatureMenu.tsxwith 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— addsjournal_entries.pinned_at TIMESTAMPTZ+ a partial index(user_id, pinned_at DESC) WHERE pinned_at IS NOT NULLso the dashboard widget's "this user's pinned entries newest-first" lookup is cheap. - Routes (auth, owner-scoped):
PUT /api/journal/:id/pinbody{ pinned: boolean }(toggles the timestamp on/off);GET /api/journal/pinnedreturns the trailing 12 pinned entries newest-pin-first. - Frontend:
PinEntryButtonin/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.jsLinkto/dashboard/notebooks/<id>, so plain click navigates and Cmd/Ctrl-click opens in a new tab natively. CompanionPinnedEntriesWidgetmounted 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=opus → audio/webm → audio/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(witherroras a recoverable side-state). Preview shows the recording audio inline for re-play, an editable title (derived viaderiveTitle()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-transcribeinroutes/journal.ts. Multipart upload (multer memoryStorage, 25MB cap, singleaudiofield). Forwards the buffer to OpenAI Whisper (/v1/audio/transcriptions, modelwhisper-1) with optionallanguagehint. Returns{ text }. Doesn't create an entry — separating transcribe from save keeps the UX flexible. - Nav: added as "Voice Entry" under Journal in
FeatureMenu.tsxwithbadge: '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:- Day-of-year rotation (default) — same prompt for everyone on the same UTC calendar day, drawn from
DAILY_PROMPTS[64]. Idempotent across refreshes. - 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 fromMOOD_PROMPTS[mood](4-6 per mood: anxious/tired/sad/frustrated/happy/calm/grateful/excited). Card recolors viaMOOD_TIERand 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.
- Day-of-year rotation (default) — same prompt for everyone on the same UTC calendar day, drawn from
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 whentotal_tagged_entries < 3. Each segment + chip is a click-to-filter button: clicking sets the dashboard'sfilterMoodstate, which propagates toGET /api/journal?mood=<m>(server-side) AND the client-sidefilteredEntriesmemo. Active-mood pill renders above the entries list with one-click clear. Backend:GET /api/journal/mood-stats?days=Nreturns{ days, total_tagged_entries, distribution[] }(distributionsorted 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
@mentionsby 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 filtering — GET /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.ts—qusesilikeon title+content with%/_escaped;taguses.contains(tags, [tag]);personuses.contains(mentions, [name]);since_daysuses.gte('created_at', cutoff). - Frontend:
apps/frontend/src/app/dashboard/page.tsx— facetuseMemoderives chip lists from loaded entries; a seconduseEffect(guarded byfiltersDidMountref) 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()inapps/backend/src/routes/journal.ts PUThandler selectscontentoff 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-mentionsprocesses up to 500 per call, returns{ updated, remaining }— caller loops untilremaining === 0 - Powers the dashboard
personchip 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).noneworks too. Existing real-UUIDnotebook_idqueries are unchanged. - Frontend:
apps/frontend/src/app/dashboard/notebooks/page.tsxfetches orphans withuseJournalEntries({ notebook_id: 'null', limit: 1 })and readstotalfrom the response to decide whether to render the card.apps/frontend/src/app/dashboard/notebooks/other/page.tsxis 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 shimcomplete()rather than calling OpenAI directly — picks up automatic per-call cost logging tollm_call_log(feature_keyschat_assistantandchat_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) andspawn_show_in_universe(one novella inside an existing universe ~$0.05-0.15). Tool-execution errors get surfaced both to Railway logs ANDservice_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 withcanon_episode_id, the open chapter's live prose is injected too (capped 8k, placed high, validatedep.season_id === seasonIdso a stale id can't leak another story). Canon mode also bumps the model gpt-4o-mini → Sonnet (WRITER_QUALITY_MODEL_KEY, since thellm.tsshim gained Anthropic tool-calling so thecanon_*tool loop runs provider-portably; Anthropic outages fall back to OpenAI viaPROVIDER_FALLBACK). Proactive continuity nudge (2026-07-10): a season-scopedbuildCanonContextalso folds a compactcomputeCanonHealthsummary (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-testedcanon-mode.test.ts) maps each call to aBibleDelta;applyDelta()appends settings/themes deduped by key and voice/tone/do-not-use strings deduped case-insensitively (viacommitBible'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_healthtool →computeCanonHealth(seasonId)(the shared season audit reused by thecanon_healthtool, the proactive canon-mode nudge, and theGET /:id/canon-healthroute) →analyzeCanonHealth(bible, {latestChapter})(pure, golden-testedcanon-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 guard —canon_consistencytool (UNIVERSE scope) →getUniverseConsistency(key)gathers the universe's published stories' bibles and runs the purefindCanonConsistencyIssues()(golden-testedcanon-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 startingcanon_route toexecuteCanonTool(chat-service.ts). Also surfaced outside chat:getUniverseCanonAudit(key)runs the same analyzers (consistency + per-story health) behindGET /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.
- Backend:
apps/backend/src/routes/ai.ts - Migrations:
038_add_note_analyses.sql,040_add_analysis_granularity.sql,043_add_sleep_mood_energy_to_analyses.sql - Reference: docs/TONE_ANALYSIS.md, docs/CHAT_NOTE_ANALYSIS_CRON.md
- Surfaced at
/dashboard/analysis
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.
- Backend:
apps/backend/src/routes/quotes.ts - Reference: docs/zenquotes-setup/
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 (
ThroughlineDemoon/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-bridgepage + the Features catalog JQ Bridge entry. Also on the dashboard asBridgeShareExample(collapsible "what your connections see vs. what you journal", reusesGET /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.tsxmetadata) - Demo ("See exactly what you'd see"):
ThroughlineDemo.tsx←GET /api/providers/demo-clients← curated fixtureapps/backend/src/data/throughline-demo-clients.ts(AI-persona journals + their therapist-facing analysis; trends only, norecommendations) - Backend:
apps/backend/src/routes/providers.ts—POST /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.tscreates NON-public personas → real back-datedgenerateJournalEntry()→ a provider-framed (3rd-person, trends-only) read → auto-cleanup. Driven byscripts/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 keepcoach_interest) no longer pool intoprovider_interest— leads live inaudience_interest(migration 492, with a backfill that MOVES the oldfor_%rows over). Routesroutes/audience.tsat/api/audience: publicPOST /interest(same payload as the old pooled endpoint — the sharedDesignPartnerFormonly swappedendpoint; audience derived fromsource) +POST /landingbeacon; super-adminGET /funnel+GET /interest. Funnel events ride the genericfunnel_eventswithfunnel='for_audience',surface=<audience>viaservices/audience-funnel.ts(aggregateAudienceFunnelpure + golden-tested;normalizeAudienceslugging). The coachingPOST /coach-interestalso fires aninterest_submittedevent so guides/coaches are funnel-visible without moving their leads. Client beacon:AudienceLandingBeaconincomponents/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 441self_report_responses(user-owned RLS, append-only,total_score/max_score/band+consent_version/consent_text/consented_atprovenance + timestamps). Routesroutes/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 permissionwellbeing_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'smy-connectionsanalysis view viaGET /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-permissiondefaultEnabledin thepermissions UI). Acquisition funnel (shipped): first-partythroughline_providerfunnel infunnel_events(survives ad-blockers, unlike PostHog) viaservices/provider-funnel.ts— stageslanding(a beaconPOST /api/providers/funnel-landingfrom /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); readoutGET /api/providers/funnel(super-admin) renders as a stage strip on/dashboard/admin/provider-interest. Access audit + transparency (shipped):migration 443provider_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." EveryGET /connections/:id/analysisview appends a row (viewer, subject,permissions_viewed, ip_hash) viaservices/provider-access-log.ts;GET /api/jq-bridge/access-loggives the sharer a transparency feed ("who's viewed your shared data") on/dashboard/jq-bridge, collapsed by the pure golden-testedcollapseAccessViews()so a viewer's rapid refreshes read as one access. Reminder cadence (shipped): opt-in periodic nudges so the longitudinal series actually accumulates —migration 444addsprofiles.wellbeing_checkin_reminder_opted_in_at/_cadence_days/wellbeing_checkin_last_reminder_at; thewellbeing_checkin_remindercron (6h,services/wellbeing-checkin-reminder.ts, heartbeat +CRON_REGISTRYtoggle +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. PureisReminderDue()is golden-tested; one-click HMAC unsubscribe (GET /api/self-report/reminders/unsubscribe); opt-in toggle on/dashboard/check-inviaGET/PUT /api/self-report/reminders. Engagement-over-time leading indicators (part 2, shipped): a second opt-in JQ Bridge permissionengagement(migration 451, defaults OFF likewellbeing_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()inservices/journal-engagement.tsis 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 ofjournal_entriesand returns null (section omitted) when there's nothing to share. Surfaced in the contact'smy-connectionsview via the sameGET /api/jq-bridge/connections/:id/analysispath (permission toggle in thepermissions UI). Self-view (see-what-you-share): the sharer previews their own cadence on the JQ Bridge dashboard viaMyEngagementCard←GET /api/jq-bridge/my-engagement(authed, the caller's owngetEngagementSignal) — transparency + a little self-motivation, next toBridgeShareExample. 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,
prepareForPublicswaps 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-onlygenerateTags.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 fixturedata/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). Componentsrehearsal-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-therapistscross-link,/write-cafehero CTA, inter-vertical cross-links. - Characters campaign + writer-pipeline promo (growth): higher-conversion landing page
/rehearsal-room/characters("what would your lead character do?") — samemode='writer'engine as/writers, sharper hook, + a public multi-platform promo generator so writers pass it on.services/rehearsal-writer-promo.tsdrafts 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 UIWriterPromoGenerator.tsx(variant="public"on the landing page,variant="admin"at/dashboard/admin/writer-promo). feature_keyrehearsal_room.writer_promo.<platform>. Admin variant also has the per-draft 🖼️ Image button (sharedPOST /outreach/image→generatePostImage, 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.tsgrades a pasted dialogue snippet against the character (grounded inMBTI_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/optionsfor the MBTI menu and the curatedsamples). UIDialogueConsistencyTool.tsx; funnels into /characters + signup. The promo generator gained a target (characters|voice) so posts can point at either page. feature_keyrehearsal_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): anonDIALOGUE_CHECK_ANON_PER_DAY=5/day (per-IP), signed-in free 25/day (per-user counter), Graphene+ (isGrapheneSubscriber) unlimited —checkDialogueLimit()returns 429code:'dialogue_limit'+tier, and the UI shows the matching nudge ("Sign in for more" / "Go Graphene+"). All counters sharerehearsal_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-charactersreturns 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 projectionextractPickerCharacters()in rehearsal-dialogue-check.ts (test) so the two pickers can't drift. - Story-home hosting (writer retention): after a run,
WritersCrossSell.tsxnudges the writer to name the story + say what it's about and "give your story a home" — persists a lightweight, anon-friendlystory_projectsrow (migration378_story_projects.sql; claimed on signup via the extendedPOST /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.tsrecordslanding(viaPOST /funnel/landingfromFunnelPing.tsx) +tried(server-side at/runand/dialogue-check, tagged bysurface);hosted+signed_upare derived exactly fromstory_projects(migration379_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.tsxgenerateMetadata. 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/, migration380_rehearsal_og_image.sql) viaPOST /runs/:id/og-image(+DELETEto clear) from/dashboard/admin/rehearsal-highlightsor an inline super-admin button on the shared page itself (shared/[slug]/page.tsx, gated byuseIsSuperAdmin). 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 astagekey (STAGE_BACKGROUNDSin the service, mirrored in frontendlib/rehearsalStages.ts); the backend fetches the live.webp, transcodes it to a 1200×630 PNG withsharp(next/og can't decode webp) and re-hosts it in the same bucket, so it's free + on-brand.:idis a run uuid for real shares, or a curated gallery slug for showcase examples — those store their background inrehearsal_share_og(migration381_rehearsal_share_og.sql, slug-keyed, since fixtures have norehearsal_runsrow), merged intoGET /runs/:idOrSlug. Reader-style page: when a spotlight background is set,shared/[slug]/page.tsxrenders 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.tsbuilds a multi-voice script (narrator frames; the persona's stable-hashedVOICE_BANKvoice speaks the modeled reactions), renders viattsSegment+concatMp3Buffers+uploadAudio→season-assets/rehearsal-audio/, stored onrehearsal_runs.audio_url/rehearsal_share_og.audio_url(migration385_rehearsal_audio.sql), spend logged under feature_keyrehearsal_room.audio(~$0.20 each). A public 🎧 "Listen" player shows on the shared page whenaudio_urlis 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.tsgenerates 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 (ffmpegzoompan) timed to the shot's dialogue rendered in the speaker's voice (ttsSegment, stable-hashedVOICE_BANK; narrator otherwise), concatenated into a 1920×1080 MP4 inseason-assets/scene-studio/. Tablesscene_projects+scene_shots(migration387_scene_studio.sql, one project per chapter,keyframe_url/audio_url/clip_urlper shot). Routesscene-studio.ts:GET /chapters,GET/POST/DELETE /projects/:episodeId,POST /projects/:episodeId/{breakdown,keyframes,assemble}. Picker (GET /chaptersvialistScenableChapters): returns a universe → story → chapter tree (ScenableUniverse[]) — seasons grouped bystory_seasons.universe(joined tostory_universesfor label/icon; null = "Standalone"), each season carryingmode/genre/status+ derivedtagsfor search and anin_progress/last_activitysignal (fromscene_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 atfinal), 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.tsgenerateBeatSheetruns ONE planning-tier call (PLANNING_MODEL_KEY= claude-sonnet-4-6, feature_keyscreenplay.beat_sheet) over every prose chapter's structured metadata (dramatic_role+ the knowledge layerreader_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), anadaptation_decision(keep/compress/merge/invent_connective/cut_context), and apage_estimatesummed against the project'starget_pagescompression 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-scenegenerateSceneFountain) render each beat into actual Fountain screenplay prose with Haiku (DEFAULT_MODEL_KEY, feature_keyscreenplay.scene_fountain), folding the two hard adaptation passes into one prompt — externalize interiority (thought → action/behavior/subtext/sparing VO, so the scene'sknowledge_reveallands) and surface dialogue in voice (grounded in the bible'svoice_brief/verbal_tics/speech_avoid) — over the scene'ssource_chapter_numbersprose. Cached per scene (only empties render unlessforce), bounded concurrency 4, failures isolated; flips status →sceneswhen all bodies exist.assembleFountainbuilds the canonical doc on demand (Fountain title page +===+ ordered scene bodies) for download. Tablesscreenplay_projects(one per season, uniqueseason_id) +screenplay_scenes(fountain_bodynow populated in phase 2) — migration411_screenplay_engine.sql. Routesscreenplay.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, migration412_screenplay_critique.sql):critiqueSceneruns a multi-lens editor panel (Sonnet, feature_keyscreenplay.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).improveScenefolds those findings into a re-render (generateSceneFountaingainedrevisionNotes) 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 onscreenplay_projects.fidelity_score/fidelity_report— the guardrail against a polished script that lost the story.getScreenplayProjectnow attaches per-scenecritiques[]+ 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-basedparseFountain(scene heading / action / character / parenthetical / dialogue / transition, tuned to our generator's output) backsexportFdx(Fountain → Final Draft .fdx XML) andexportPrintHtml(Courier + screenplay-margin HTML the browser saves as PDF — no PDF dependency). Scene Studio contract:sendSceneToStudiohands a rendered screenplay scene's Fountain to Scene Studio as its shot-breakdown source —generateSceneBreakdowninscene-studio.tsgained an optionalsourceTextOverrideso it dramatizes a real, bounded scene (explicit dialogue + action) instead of guessing a beat from raw prose, anchored to the scene's firstsource_chapter_numberschapter (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-render → Check 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.tsgenerates a structured brief (gpt-4o, feature_keyrehearsal_room.promo.brief— invents the fear-creature + payoff, beats, 15s cut, per-shot Veo prompts, Midjourney stills prompts, captions) stored inrehearsal_promos(migration386_rehearsal_promos.sql, keyed byshare_key). Then stills (gpt-image-1, 3 images →season-assets/rehearsal-promos/) and admin-uploaded finished video/images (multipart, 200MB cap) with adraft → assets → final → publishedstatus. Routes onrehearsal-room.ts:GET /promos,GET/POST(/brief|/stills|/upload|/status) /promos/:key,DELETE /promos/:key.- Consistency + asset pipeline (migrations 388
charactersjsonb / 389style_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 look —generateCharacterReferences("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_urlis preserved so repeated restyles don't drift. Medium from the reference image: because thestyle_anchoris usually mood/lighting (not a medium) and Kontext can only condition on the face image,describeImageStyle()runs a gpt-4o-mini vision call onstyle_image_urlto 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-imageflux-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_keysscene_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'svisual_styleand 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'svisual_style+ poster/chapter-cover (getSceneProjectalso returnsshow_visual_style/show_poster_url/chapter_cover_url) when present. The scene style_image_url defaults fromchapter_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/clipanimates 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 prefersclip_urlwhen present (scale/crop to 1920×1080,tpadclone-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/clipsreturns 202 +kickoffBulkShotClips()(setImmediate, sequential, per-shot isolation, skips shots that already have a clip); progress lives on durablescene_shots.clip_started_at/clip_errorcolumns (migration390_scene_shot_clip_status.sql) and the frontend pollsGET /projects/:id/clips/status(listShotClipStatus→ per-shotdone|generating|failed|pending|no_keyframe+ summary) every 6s. "🎬 Animate all shots" button + adone/total · failed · rendering…chip; per-shot "🎬 Animate" still available. Runtime-untestable locally (needsREPLICATE_API_TOKEN+ prod). "▶ Preview clips" — a free client-side "virtual stitch": a modal<video>plays every shot'sclip_urlback-to-back (key=idx remount so autoPlay fires per clip,onEndedadvances + loops), muted/silent, with prev/next + ←/→. No server render — lets you eyeball the cut before paying for the realassemble(which adds dialogue TTS). - Editable cut (Phase 4a, migration
395_scene_shot_edit.sql): shots gaintrim_start_s/trim_end_s/transition(cut|fade);updateShotaccepts them +reorderShots(episodeId, shotIds)(routePOST /projects/:id/shots/reorder) setsidxto reorder (reflows the keyframe grid, the preview, and the assembled cut).assembleSceneAnimatichonors trim (head-ss/shortened duration) +fade(dip-through-black via ffmpegfadefilters on each segment;cutkeeps the existing concat) +dissolve(cross-dissolve into the next shot). When any non-final shot isdissolve, assembly switches from the concat demuxer to a singlexfade/acrossfadefiltergraph — segments chain left→right, cross-dissolving (xfade=transition=fade) where the prior shot dissolves andconcat-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 sameshots/reorderroute), 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_urlholds an optional scene score —generateSceneMusic(episodeId)builds an instrumental bed from the project's visual-style mood via Replicatemeta/musicgen(budget-cappedscene_studio.music, latest-url only), previewable standalone in the UI (🎵 Generate score/🎵 Re-score+ an inline<audio>). Whenmusic_urlis set,assembleSceneAnimaticducks the bed under the dialogue — ffmpegsidechaincompress(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). Nomusic_url⇒ assembly is byte-for-byte unchanged; mix failures fall back loudly (service_errors) to the un-scored cut. RoutePOST /projects/:id/music. - Karaoke captions (migration
404_scene_karaoke.sql): whencaptions_burnis on,captions_karaokeupgrades the burn from static SRT to word-synced karaoke — each word highlights as spoken. Per dialogue shot,assembleSceneAnimaticWhispers the rendered TTS (transcribeWordTimestamps, meteredscene_studio.karaoke), realigns to the original line for casing/punctuation, offsets word times by the shot's timeline position, and builds one libass ASS (\ktags) with the font UU-embedded — via the sharedkaraoke-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) atbrand_position(bottom_rightdefault /bottom_left/top_right/top_left).assembleSceneAnimaticdraws 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 (migration407_scene_brand_logo.sql):brand_image_url— an uploaded PNG (POST /projects/:id/brand-logo→uploadSceneBrandLogo) overlaid (scaled to ~⅐ frame width, ffmpegoverlay) at the same corner; takes priority overbrand_text. UI: upload/remove logo in the branding row. - Cinematic finish (Maxell option 3/3, migration
406_scene_cinematic_finish.sql):cinematic_finishboolean → a final-grade passeq=contrast=1.06:saturation=1.05,vignette=PI/4,noise=alls=6:allf=tapplied 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 (officialsync/lipsync-2-proby default (general video/audio inputs; kling-lip-sync rejected non-Kling clips with E006); overridable viaSCENE_LIPSYNC_MODEL; budget-cappedscene_studio.lipsync, priced inGEN_PRICE_USD) →scene_shots.lip_sync_url(+ cappedlip_sync_versions).lipSyncBufferresolves the model's latest version (community ids likebytedance/latentsync404 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).assembleSceneAnimaticpreferslip_sync_urloverclip_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, solipSyncShotmeasures the rendered TTS duration (probeDuration) and trims the take toaudioDur + LIPSYNC_TAIL_S(default 0.4s, envSCENE_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-syncbutton + a🗣 syncedbadge 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 (trimShotTake→POST /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*_versionsentry (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-animatebutton + a hero⟳ re-animatebadge turn amber. Its lip-sync is stale when the clip changed after the take (clipTs > lipTs) →🗣 Re-syncgoes amber. The assembled cut is stale when any shot's used asset (lip-sync > clip > keyframe) is newer than the activeanimatic_versionsentry (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. RoutePOST /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 stampkeyframe_started_at/lip_sync_started_atat the start and clear them on completion (error →keyframe_error/lip_sync_error), mirroring the clip'sclip_started_at. The frontend derivesrenderingShotsfrom 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⚠ failedbadge 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, defaultkwaivgi/kling-v1.6-standard) andSCENE_LIPSYNC_MODEL(defaultsync/lipsync-2-pro;lipSyncBufferversion-pins community ids + maps input keys per family). New ids should also be priced inGEN_PRICE_USD(unknown ids meter at $0 until added). Aspect ratio (migration398_scene_aspect_ratio.sql):scene_projects.aspect_ratio(16:9default |9:16vertical |1:1square) flows through keyframe gen (ImgAspect, gpt-image size map adds1024x1536), clip gen (Klingaspect_ratio), andassembleSceneAnimatic(output dimsVW×VHderived from it — 16:9 stays byte-identical at 1920×1080, including the Ken Burnsscale=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'sstyle_image_urlto that character's portrait (updateSceneProjectacceptsstyle_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 (setSceneShowStyle→POST /projects/:id/show-style) — writing the chosen image tostory_universes.style_reference_image_urlor, for standalone shows,story_seasons.style_reference_image_url(migration409), whichuniverse-canon-gen'suniverseStyleprefers — 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).getSceneProjectenriches each character withvoice_default(the story voice) so the card shows "Auto · story: <Voice>"; a 🔊 dropdown (bank fromGET /api/story-seasons/voice-bank=VOICE_BANK+ narrator, each with an ElevenLabspreview_url) with a ▶ sampler (pending selection → ▶ to hear → Apply to commit, so you sample before changing anything) sets a per-scene override stored on thecharactersjsonb (POST /projects/:id/characters/voice {name, voice_id}, empty clears).assembleSceneAnimatic+lipSyncShotusebuildSpeakerVoiceMap/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 (migration408_scene_lipsync_voice.sql):lipSyncShotrecordsscene_shots.lip_sync_voice_id(the voice baked into the take);getSceneProjectflagslip_sync_voice_stalewhen 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
shotKeyframeBuffercomposites 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 fromscene_shots.place_key→universe_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/staticoverride the alternating default; NULL keeps it byte-identical).updateShotaccepts it; UI is a 🎥 dropdown in the per-shot Cut row. RoutePATCH /shots/:shotId{ camera_motion }. - Trailer cards (migration
402_scene_trailer_cards.sql): optionalscene_projects.title_card({title, subtitle}) +end_card({headline, sub, cta}).assembleSceneAnimaticrenders each as a drawtext card segment (dark bg, centered text, fade in/out, silent audio —renderCardSegment+getFontPathfrom season-video) and unshifts/pushes it ontosegs, 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.updateSceneProjectsets 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 (
getChapterMeta→GET /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 fromresolveChapter+ 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 forTRAILER_SYSTEM_PROMPT— trailer 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). RoutePOST /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 inCOMMERCIAL_SYSTEM_PROMPT— ad 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 sharedSceneBreakdownMode = 'scene' | 'trailer' | 'commercial'. RoutePOST /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):mintSceneShareSlugmintsscene_projects.share_slug(reusesmakeShareSlugfrom rehearsal-room; requires an assembledvideo_url). Public no-auth routeGET /api/scene-studio/shared/:slug→getSharedTrailerreturns{title, video_url, poster, title_card, end_card}. Public page/trailer/[slug]plays the cut + the Odessa-invite end card;layout.tsxgenerateMetadataunfurls 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):assembleSceneAnimaticbuilds 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 ffmpegsubtitlesfilter whencaptions_burnis set (updateSceneProjecttoggles it; a libass-less ffmpeg fails loudly to service_errors and ships un-burned + sidecar). NULL/false ⇒ video unchanged. UI: aCCcheckbox by the aspect picker + acaptions (.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) callsassertSceneBudget()which sums today's (UTC)scene_studio.*cost fromllm_call_logand throwsBudgetExceededError(→ HTTP 429) once it hitsSCENE_STUDIO_DAILY_CAP_USD(env, default $25,0disables). 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.mp4with upsert — overwrote). Each generated URL is appended toscene_shots.clip_versions/scene_projects.animatic_versions(jsonb[{url, created_at, …}], capped at 12);clip_url/video_urlstay the active pointer. Restore an earlier take via the existingPATCH /shots/:id({ clip_url }) /PATCH /projects/:id({ video_url }) — both validate the url is a known version. UI: per-shotv1 v2 …take buttons under the clip, and an animatic "Versions" list with make-current + per-version download.
- Consistency + asset pipeline (migrations 388
- LinkedIn outreach generator (growth):
services/rehearsal-outreach.tsdrafts 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 (separaterehearsal_rate_limitsbucket via namespaced ip_hash, no migration); personalized DMs are super-admin only. Shared UIOutreachGenerator.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_keyrehearsal_room.outreach.{post,dm}. Matching post image (admin): a per-draft "🖼️ Image" button callsPOST /outreach/image(super-admin) →generatePostImage()inrehearsal-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.
- Backend:
apps/backend/src/routes/organizations.ts·teams.ts - Roles helpers:
apps/backend/src/database/roles.ts - Migration:
003_add_orgs_teams_roles.sql - Reference: docs/ORG_TEAM_ROLES.md, docs/ORG_TEAM_API.md
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
getUserDropsviaPREMIUM_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).
- Migration:
028_add_encouragement_badges.sql - Reference: docs/ENCOURAGEMENT_BADGES.md
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,sendNudgew/ shared-circle + daily-cap checks,creditNudgersForCheckin,getNudgeInbox/markNudgesSeen) ·routes/accountability.ts→/api/circles(allauthenticateUser). Payoff hooked intoroutes/workout-window.tsright after the check-in insert (mutateBalancereason: '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 intoDashboardLayouton 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+ purerankCircleWeekinservices/accountability.ts→GET /api/circles/:id/leaderboard. No new tables — readsworkout_window_checkins+accountability_nudges. Ranker golden-tested (tests/circle-momentum.test.ts). - Migrations:
470_accountability_circles.sql(circles/members/nudges;sent_daygenerated + 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 (clonesStoryWalkScene'sMarblefire + r3fonClickfor 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 inFeatureMenu(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 theFeatures 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 toRING_TARGET(8) with clearly-labelled indigo practice walls so the room's never sparse — persona breaks are cosmetic only (no nudge, no coins; a fabricatednudgee_idwould 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: whenNEXT_PUBLIC_RESET_AUDIOBOOK_SEASON_IDis set to a produced Graphene story-season, the page embeds the real/seasons/<id>/embedplayer; 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 viaGET /api/coaching/resolve-refso the back-end override follows the front-end commission. Mapping stored oncoaching_referral_codes.clickbank_affiliate(migration 374), set viaPOST /api/coaching/admin/affiliate-map+ the "ClickBank affiliate map" mini-form on /dashboard/coaching. - Shared config:
apps/frontend/src/lib/coaching.ts—DREAMPRO_HOUSE_REF(envNEXT_PUBLIC_DREAMPRO_HOUSE_REF, defaults to seed codeSEEDDEMO) +startPlanHref()+RESET_AUDIOBOOK_SEASON_ID. - House coach provisioning:
enrollClientrequires a real (non-seed) coach referral code, so the native funnel needs one in prod.services/coaching-provision.tsprovisionHouseCoach()idempotently creates the house channel-partner → coach (active) → coach referral code (allis_seed=false);POST /api/coaching/admin/provision-house-coach(super-admin); one-tap button on/dashboard/coachingreturns the code → setNEXT_PUBLIC_DREAMPRO_HOUSE_REFto it. - Linked from
/coaching(featured-product band). Reuses the shipped/start+enrollClientflow. - Per-chapter QR links:
POST /api/coaching/admin/links/bulk(super-admin, idempotent bytemplate_slug+label) creates onecoaching_linksrow per item with a distinct utm; the "Generate 30-Day Reset chapter links" button on /dashboard/coaching builds them fromRESET_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 + flatintervals[]),movements.ts(bodyweight library, pattern/level-tagged, reserves alottiekey 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.tsmatchChains(date, {includeUserIds|excludeUserIds})+daily-chain-generator.tsruns 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.tsgenerateAllWeeklyBattlesmakes a red-vs-blue battle per cohort + a global one;/battle/current+/battle/:weekStartreturn 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 + thecohort_team_idcolumn 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/leaderboardgated to cohort members / the cohort's coach / super-admin (canViewCohort()). Purpose-built — NOT the participants/citizen-science competition engine. UI:CohortLeaderboard.tsxon/dashboard/my-coaching("Your cohort",/mereturnscohort) + 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.tsgenerateAllWeeklyBattlesmakes a red-vs-blue battle per cohort + a global one;/battle/current+/battle/:weekStartreturn 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). Servicecohort-encouragements.ts(recordEncouragementuploads the real mime type — NOT forced mp3;getNextTeammateEncouragement,listMyEncouragements,retractEncouragementsoft-delete + storage cleanup;NoCohortError). Routes oncoaching.ts:POST /api/coaching/encouragement(multeraudio, 10MB) ·GET /encouragement/next(204 none) ·GET /encouragement/mine·DELETE /encouragement/:id. UICohortEncouragement.tsxon/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/nudgesometimes (TEAMMATE_NUDGE_RATE0.35) folds in a teammate clip assource:'teammate'— pre-recordedaudio_urlreturned 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); MentraplayAudioWebM 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), andworkout-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 viaapps/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.tsgetPartnerOverview()· 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 viaPOST /api/creator/payouts/onboard-link(reuses the creator-payouts rails), and the coach's own coaching earnings viaGET /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(and063_google_calendar.sqladdsgoogle_event_idfor sync) - Auto-sync: when
due_dateis 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 board —
product_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.ts—GET/POST /api/admin/portfolio,PATCH/DELETE /:id(super-admin) + publicGET /api/portfolio(status-filtered to live/beta/paid, no auth). - Public brand hub: the public
/featurespage rendersBrandHubfrom 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) renderingPortfolioBoard+FeatureIndexView./dashboard/admin/featuresredirects 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
/tasksClaude 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, nogoogleapislibrary) - 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.tsxcallsPOST /run-if-staleon first open per session; rendered inline viaRoutineRunViewwith 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 intoWeeklyReviewshape, also builds the markdown used for "save as entry" - Routine executor branch:
actionType === 'weekly_review'inapps/backend/src/routes/jq-routines.ts - Frontend hook:
useWeeklyReviews,useGenerateWeeklyReview,useSaveReviewAsEntryinapps/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 taggedweekly-reviewand 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:
spawnInstanceinapps/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 withrecurrence: { type: 'custom_interval', interval: 2 }and auto-computednext_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.ts—useLifeMap+useRebuildLifeMap - Backend routes:
apps/backend/src/routes/life-map.ts—GET /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) andlife_map_edges(source_id, target_id, edge_kind, weight) - Built-in JQ Routine
rebuild_life_maprunson_open_if_stale_24h— daily refresh without user action - Extractor categorizes each node with BOTH
life_domain(powers the Spheres viewpoint) andlife_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 tomention_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:
PeopleViewcomponent inapps/frontend/src/app/dashboard/life-map/page.tsx. Pure SVG, no extra backend — uses the shareduseLifeMapgraph. - Empty state shown when no person nodes exist, suggesting the user mention someone by name in an entry or use
@namein 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=Ninapps/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)inapps/frontend/src/hooks/useLifeMap.ts - Renderer:
TimelineViewinapps/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.
Semantic Node Search
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.sqladdsembedding 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 OpenAItext-embedding-3-small, loads the user's embedded nodes, computes cosine similarity in-process, returns{ results: [{ node, score }] }ranked.POST /api/life-map/embed-missingwalks up to 100 un-embedded nodes and fills them in (idempotent; safe to call repeatedly). Both inapps/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) +useEmbedMissingLifeMapinapps/frontend/src/hooks/useLifeMap.ts.embeddingText(n)on the backend combineslabel · node_type · life_domain · life_tierso 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.sqladdspinned_x DOUBLE PRECISIONandpinned_y DOUBLE PRECISIONnullable columns plus a partial index on pinned rows. - Backend: PATCH allowlist in
apps/backend/src/routes/life-map.tsextended withpinned_x/pinned_y, clamped to the SVG viewBox (0..960, 0..600). - Frontend hook:
usePinLifeMapNodeinapps/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_yand marks itfixed: trueso the per-iteration displacement step skips it. Mouse event handlers translate clientX/Y → SVG viewBox coords, streamdragPosinto a local state override, and commitpinned_x/pinned_yonmouseup(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/:idinapps/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) inapps/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, sumsmention_count+ takes earliestfirst_seen_at, then deletes the source. Self-loops after rewrite are dropped. - Frontend hooks:
useUpdateLifeMapNode,useDeleteLifeMapNode(optimistic across the shared graph cache),useMergeLifeMapNodesinapps/frontend/src/hooks/useLifeMap.ts - Drawer UI:
✎ Edit/⇢ Merge/✕ Deletebuttons 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).
← Backpops; closing the drawer clears. Merge navigates to the target after success. GraphSearchcomponent 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 (
@slugfor person nodes,#slugfor project/topic nodes) and creates the task via the NL-command pipeline - Backend:
GET /api/life-map/nodes/:id/contextinapps/backend/src/routes/life-map.ts - Frontend hook:
useLifeMapNodeContextinapps/frontend/src/hooks/useLifeMap.ts - Component:
NodeDetailDrawerinapps/frontend/src/app/dashboard/life-map/page.tsx
Note ↔ Task Bidirectional Links
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:
- Task list — any task with
source_entry_idshows a small "📝 From note: [title]" link that jumps to the original entry. - 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>. - 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/applynow writes the link intosource_entry_id(notdescription).GET /api/user-tasksbatches a second lookup to attachsource_entry: { id, title }to each task that has one.GET /api/journalandGET /api/journal/:idboth attachlinked_tasks: [{ id, title, status, priority, due_date }]to each returned entry (batched per list; single lookup on detail).source_entry_idadded to the PATCH allowlist so the link can be managed programmatically.
Frontend:
LinkedTasktype inapps/frontend/src/hooks/useJournalEntries.ts;source_entry/source_entry_idadded to the user-tasks types inapps/frontend/src/hooks/useUserTasks.ts.- Swap renderer in
apps/frontend/src/app/dashboard/notebooks/[id]/page.tsx; banner inapps/frontend/src/app/dashboard/entries/[id]/page.tsx; "from note" chip inapps/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:
handleCopyConversationinapps/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.tsx— wall-shaped overview (redone 2026-07-20). The top is a warm, framed editable wall glance (components/family/FamilyWallPreview.tsx) that mirrors the published/family-walldevice screen (dawn palette, live clock/greeting, inline-editable "Tonight's dinner" viaPUT /api/family/meals/:date, and a compact members grid whose due-today routines are checkable — reusing the page'stoggleCheckin). 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 byuseFamilyToday(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 aGET /api/family/today?date=YYYY-MM-DDrollup 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 afamily_membersrow linked to their own account (linked_user_id = parent_user_id, relationshipparent) 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 thegetFamilyTodayRollupmembers; the/dashboard/familycard 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'slinked_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 includemonthly/quarterly/annually/as_neededso 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 thefamily_membersrow it links to. Parent flow: on/dashboard/jq-bridge→ Add Connection → type Family, an optional "Link to a family member" picker (unlinked members fromGET /api/family/members) setsfamily_member_idonPOST /connections(validated: family-type only + member owned by the caller). Accept flow:POST /api/jq-bridge/acceptnow runs undermaybeAuthenticateUser(soft-auth — a signed-in acceptor gives us their user id without blocking anonymous accepts), and on success setsfamily_members.linked_user_idto the acceptor. The go/no-go guard is the pure, golden-testedresolveFamilyLink()(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/familyhas a "🔗 Invite to link account" footer action (inline email →POST /api/jq-bridge/connectionswithconnection_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 onGET /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). Servicefamily-day-planner.ts(gatherPlannerContextstrips name/birthdate;generateDayPlan→ structured JSON blocks viacompletegpt-4o-mini,feature_key: family.day_planner). Tablefamily_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]. FrontendDayPlannerPanel— generate → drag-reorder → ✕-deselect → "Keep this plan". Spec + the follow-on goal-formation nudge: FAMILY_DAY_PLANNER.md. - Upkeep freshness —
upkeep-freshness.ts(pure, golden-testedcomputeFreshness: last-done + interval → green/yellow/red + %fresh). Wired two places: (a) routine freshness dots —getFamilyTodayRollupattachesfreshnessper routine (cadence→interval, last check-in→last_done, 400-day bounded fetch), rendered as a dot inRoutineRow; (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 viafamily_maintenance_items.room_id(FK added in 588,ON DELETE SET NULL).getHouseViewreturns{ 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 viaPATCH /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 CornerstoneWaitlist → POST /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 's bskyPostreply 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.sql—family_wall_displays(parent_user_id, unguessabletoken, 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.ts—getFamilyTodayRollup(parentUserId, date)+ the pureisRoutineDueOnDatewere extracted from the/todayroute so the wall serves the exact same computed shape (single source of truth;/todayis now a thin wrapper). - Pure display projection
apps/backend/src/services/family-wall-core.ts—projectWallViewis 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) intests/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, stampslast_seen_at; returns{ view, theme }or 404 when unknown/revoked), andPOST /api/family/wall/view/:token/checkin— PUBLIC (opt-in tap-to-check-off, see below). Both public routes in the route-auth audit'sINTENTIONAL_PUBLIC(the token IS the auth). - Tap-to-check-off (migration
519_family_wall_checkin.sql—allow_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,checkinFromWallre-scopes the routine to the wall's ownparent_user_idserver-side (viafamily_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 routineidonly whenallow_checkinis 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.sql—screensaver∈ off/dim_clock/fireplace/aurora,idle_after_seconds30–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
donecheck-ins.computeStreak(pure, golden-tested) counts consecutive days ending today (or yesterday if today isn't done yet, so it stays "alive");computeMemberStreaksdoes one grouped query inresolveWallViewand passesstreakByMemberIdinto 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.sql—speak_message+message_audio_url): the message banner can be spoken in the parent's cloned voice viasayInUserVoice(the jq-voice pipeline,featureKey: 'family.wall_message', cache-first). Rendered eagerly on save (applyMessageAudioinfamily-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/NeedsConsentError→message_audio_urlnull + avoice_warningsurfaced 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; usesuseParams()not the server params-Promise). Management UIapp/dashboard/family/wall/page.tsx— create/copy-link/open/theme/toggle-routines/turn-off/delete + a "live now / Nm ago" liveness badge fromlast_seen_at. Linked from/dashboard/familyheader. - 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 viaGET /api/family/wall/view/:token/admin(authed; returns{ is_owner, members:[{id,display_name}] }only whenreq.user.id === display.parent_user_id, else{ is_owner:false })./dashboard/familyreads?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.tsxsurfaces the full ability set + two large image-ledHighlightBandsections (Bedtime / sleep stories, Off the couch) whose painterly images are generated offline by(see CONVENTIONS "Painterly marketing-section images"). SEO cluster:scripts/generate-painterly-images.mjs/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.tskidChatTurn), in order: config enabled + un-revoked → per-child daily cap + rate limit → local safety pre-filter (the Sophia stand-in,family-kid-safety-core.tslocalSafetyVerdict— 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;sophiaSafetySOPHIA_URLseam for real cicero.sh NLU) → moderate the child's message (OpenAIomni-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 (purefamily-kid-chat-core.tskidSystemPrompt— 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 tofamily_kid_chat_messagesfor 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), callsresolveWallViewfor the grounding context (so JQ answers about exactly what the wall shows — a panel that's off → "not on the wall"), andcomplete()(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; managementallow_asktoggle. - Decide-locally pre-filter (the Sophia stand-in) — BUILT.
askWallnow 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:sophiaClassifyroutes intent classification to a local cicero.sh NLU whenSOPHIA_URLis 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 fromallow_checkin— check-in taps existing items; editing creates content): three public token routes gated onallow_editing, each re-scoping the write to the wall's own parent —POST /api/family/wall/view/:token/shopping-add(shoppingAddFromWall→addShoppingItem),/meal-set(mealSetFromWall→setMeal, blank title clears),/note-add(noteAddFromWall→addNote). Service helpers infamily-wall.tsvia a sharedresolveEditableWall(token)guard; all three in the route-authINTENTIONAL_PUBLICset. 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-backgroundsjournal art (NOT the counsel-gated family-photo layer) —GET /api/family/wall/backgrounds(auth,listMyWallBackgrounds→ distinctjournal_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):detectWallActionmaps an utterance →{shopping_add|meal_set|note_add|reminder_add}deterministically (no LLM);looksLikeActiongates aparseActionLLMfallback (gpt-4o-mini, strict JSON) for fuzzier phrasing;coerceActiontrust-boundaries the client-echoed action on/act.askWalltries 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'sowner_member_id; on a shared wall with no match, JQ returnsneeds_who+ the member list and the wall shows member buttons (chooseWho→POST /api/family/wall/view/:token/actwithmember_id). Shopping/dinner/note never prompt. - Reminders:
family_reminders(one-off, member-assigned — NOT a recurringfamily_routine;due_atcolumn reserved, unused in v1). Servicefamily-reminders.ts(addReminderre-scopes the member to the owner,getRemindersByMember,completeReminder). Shown on the assignee's wall member card (📌); reminderidexposed only on anallow_checkinwall (tap-to-complete →POST /api/family/wall/view/:token/reminder-check,reminderCompleteFromWall). Both new public routes in the route-authINTENTIONAL_PUBLICset.
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.tsisQuietNow— 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 mirrorlib/quiet-hours.ts. - Service
family-intercom.ts:listScreens(online vialast_seen_at+ shared/personal + receivable members — personal→owner, shared→home members viafamily_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); publicGET /api/family/wall/view/:token/inbox+POST /api/family/wall/view/:token/intercom-ack(token-scoped, inINTENTIONAL_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_urlcolumn 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.ts → listRecentSleepStories() = 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 (applyMessageAudio → sayInUserVoice) 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_ANNUAL → family_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 itemidis 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.ts — wmoToWeather (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) + purefamily-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-wallshow_mealstoggle).
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(kindevent|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;getWallActivitiesreturns upcoming events + active ideas with derived YouTube thumbs). Purefamily-activities-core.ts—youTubeId(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_members—user_idPK, 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 readGET <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 +engineDownon outage, never throws to the client. Base URL is one env valuePORCHHEARTH_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 setsnoindex) →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" 🏘️ inFeatureMenu(Journal group, next to Family). - Cross-product: coordinated with the PorchHearth session via crosstalk. Phase 1 (
GET /public/needsgive-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_tokenserver-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 addsfamily_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) + purefamily-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)infamily-calendar.tsrefreshes each active source's token, fetches today's Google events, and — via the purenormalizeGoogleEvent(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.resolveWallViewfetches when the wall'sshow_calendaris on and passescalendarinto the projection. Span (migration524—calendar_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-wallshow_calendartoggle + 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/settings → Fireplace 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/settings → Features 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.sqladdsprofiles.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-flagsinapps/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.ts—useFeatureFlags()hook +peekFeatureFlags()synchronous reader backed by sessionStorage cache so the dashboard first-paint gating doesn't wait on a round-trip. - Settings UI:
FeatureTogglesSectioninapps/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
dreamstemplates and the public-facing community layer lives at/dreampro. The legacy/open-energyroutes 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
- docs/OPEN_ENERGY_ROADMAP.md — original 10-phase plan (all phases complete)
- features/open-energy.md — deep feature dive
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 templatesapps/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:
participantstable +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(seedsopen-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+sponsorshipstables (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_emailandcontact_nameare 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:
opportunitiestable, 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
participantswithseeking_sponsorship,sponsorship_pitch,external_links(JSONB, 6-link cap) - New
sponsor_outreachtable 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-digestinapps/backend/src/routes/dreampro.ts— secured byx-cron-secret. Computes the most recent UTC Monday asweek_start_at, looks back the prior 7 days, groups by recipient, and sends one digest per opted-in recipient with alinked_user_id. - Vercel cron route:
apps/frontend/src/app/api/cron/dreampro/sponsor-outreach-digest/route.ts, scheduled0 9 * * 1(Mondays 9am UTC) invercel.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 capturesdelivered+delivery_errorfor 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(addsreplied_at+reply_notescolumns + 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 byparticipants.linked_user_id == auth.uid());PATCH /api/dreampro/my-outreach/:id(togglereplied_atand updatereply_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_rsvpstables,meetup_event_rsvp_countsview - 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) anddreams.min_grade_band. Newclassroom_progress_reportstable (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 bands5-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 inapps/backend/src/routes/dreampro.ts. - Migration:
073_kit_waitlist.sql—kit_waitlisttable with UNIQUE(email), bucketedkid_age_bands TEXT[], optional 2-charcountry_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/kitsshows a live "N families on the waitlist · M this week" badge above the four-box grid for social proof. Powered by a public unauthenticatedGET /api/dreampro/kit-waitlist/count(counts only, no PII, 60sCache-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_kidsflag, 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— callsrecomputeCompetition()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_upvotestables - 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/videoswith 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.sql → scene_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.
getSceneProjectreturnscanon_key = universe || season:<id>(theuniverse_characters/universe_placeskey is free-form, no FK), andlistScenePlaces/placeImageMap/ import / save-to-canon all usecanon_key, so standalone shows pull characters + places into Scene Studio exactly like universe shows.universe-canon-gen'suniverseStyleresolves aseason:<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, meteredscene_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 & placesaction + per-place✨ image/↻generate (calls the universe-canon image route withcanon_key). Both surfaces: also managed on the Graphene season page —SuperAdminPanelrendersUniverseCanonPanelfor standalone seasons (universeKey=season:<id>,seasonSeedId=<id>), where its Import button becomes ✨ Seed from story →seedSeasonCanon(POST /api/scene-studio/seasons/:id/canon/seed, picks the season's first chaptered prose). Sameseason:<id>namespace Scene Studio reads, so edits in either surface show in both. Batch image gen ("✨ Generate all" inUniverseCanonPanel):generateAllUniverseCanonImages(canonKey, {regenAll, onProgress})(universe-canon-gen) walks every character then place, filling in MISSING images by default (orregenAll), per-item try/catch, stops early on the daily budget cap. Season pathPOST /api/scene-studio/seasons/:id/canon/generate-imagesis durable — wrapped in aseason_pipeline_events'canon_images'stage (startEvent/tracker.progress/complete), so it survives a Railway restart (autoCleanStuckEventsrecovers stuck rows) and shows live in the globalActivePipelinesWidget(🖼️ Canon images). Universe-editor pathPOST /api/story-seasons/universes/:key/canon/generate-imagesis 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 uniquekeyfrom the season title (vianormalizeKey+ a de-collision suffix), setslabel=title /description=premise /canon.synopsis_hint=premise /generated_via='inspired_by', and calls the existingcreateUniverse. RoutePOST /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 inSuperAdminPanel(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 becauseseason.universeis set (generateCanonCandidates/promoteCanonCandidatesincanon-harvest.tsrequire 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_voicesviaupsertCharacterVoice) and clears any per-scene override, so a voice set in one chapter resolves everywhere; the card shows a★ storybadge. (b) Character art —generateCharacterReferences/regenerateCharacterReference/uploadCharacterReferencecallseedCanonFromSceneCharsto 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;getSceneProjectreturnsstory_ref_image_urlper character (canon match by name) and the bible shows it as a★ storyfallback when the scene has no ref yet. (c) Style —getSceneProjectreturnsshow_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.ts — sendSlackDM(idOrEmail, text) (resolves a U… member ID or email via users.lookupByEmail → conversations.open → chat.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.
- Backend:
apps/backend/src/routes/email.ts - Reference: docs/PASSWORD_RESET_SETUP.md, docs/RESEND_SMTP_SUPABASE_SETUP.md, docs/EMAIL_DELIVERABILITY.md
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.
- Backend:
admin-import.ts·firebase-import.ts·mongodb-import.ts - Reference: docs/FIREBASE_IMPORT_GUIDE.md
Mobile App (React Native / Expo)
Mobile companion app sharing core features.
- App:
apps/mobile/ - Reference: docs/REACT_NATIVE_PLAN.md, docs/REACT_NATIVE_QUICKSTART.md, docs/MOBILE_FEATURE_PARITY_PLAN.md
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()readsNEXT_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_clickedPostHog event with asourceproperty to distinguish funnel stages). Defensive validation rejects values not starting withhttps://. 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.localon 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.ts—mapFormFields()asksgpt-4o-mini(feature_keyjq.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/map—apps/backend/src/routes/jq-form-fill.ts, registered at/api/jq/form-fillinindex.ts(auth viaauthenticateUser). - 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) incontent.js, orchestration inbackground.js(FILL_ACTIVE_FORM). - Test:
apps/backend/src/services/tests/jq-form-fill.test.tslocks the guardrails (forbidden fields stripped pre-model, key allow-listing, unknown-id drop, confidence clamp). Run ESM tests withNODE_OPTIONS=--experimental-vm-modules(npm testsets 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.sql—jq_forms(owner, title,fieldsjsonb schema) +jq_form_responses(form_id, respondent_id nullable,valuesjsonb). RLS on, no public policies — all access via the backend. - Backend service:
apps/backend/src/services/jq-form-creator.ts—extractFormFromImage(vision,image_urlaccepts an http(s) URL or adata: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.tsat/api/jq/forms—POST /extract,POST /,GET /,GET /:id(public),PATCH /:id,POST /:id/responses(public),GET /:id/responses(owner). Mounted with a 15mb JSON limit inindex.tsso 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 atapps/frontend/src/app/forms/[id]/page.tsx+FillFormClient.tsx. - Test:
apps/backend/src/services/tests/jq-form-creator.test.tslocksnormalizeFormSchema.
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/say → session.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.tsorderItemsByRoute(items, sections)— groups items by section, orders stops bysort_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.tsat/api/aisleask(allauthenticateUser, 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, andGET /: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] }→importSourcesextracts every recipe (≤10, cost-guarded) + splits every pasted list, folds them via puremergeShoppingSourcesinto 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 toitem.sourcevia puremergeSourceLabelsrather than silently dropping the dup — provenance survives). Returns{ sources, merged, added, enriched, failed }. The same enrich-on-dup fix also lands in single-recipeimportRecipe(a dup is now tagged, not just skipped). BothmergeShoppingSources+mergeSourceLabelsgolden-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}→scanSectionSignreads 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-testedparseSectionScan. Dashboard Store-layout tab: "📷 Scan sign". Rides the 15mb/api/aisleaskbody 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-sectionendpoint, 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:; intentscanAisleSignaisle/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+ purebuildScanAck(session.ts). First camera-IN slice in the glasses app (was speak-only). Needs a camera-capable device + backendOPENAI_API_KEY; degrades to a spoken explanation on any failure. Targets the wearer's DEFAULT store (scanAisleSectionreadsgetOrCreateStores()[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 (theaisle/scancommand 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-sourcesendpoint 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:; intentscanRecipeToListaisle/recipe(explicit "scan/add/import this recipe" only, checked before the aisle-sign scan); capture + spoken confirm:scanRecipeToShoppingList+ purebuildRecipeScanAck(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 clientfetchAisleRoute; intentaisle/recap(checked before next/got so it recaps instead of advancing); purebuildListRecap+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; pureparseAddItem(intent.ts) — explicit "add/put <X>" command only (guards the recipe/aisle capture commands + bare pronouns so a passing sentence can't create items); handleraddSpokenItem(session.ts). Completes the hands-free AisleAsk verb set: add · next · got-it · recap · scan aisle · scan recipe.
- Glasses capture path (Mentra Live, hands-free): say "scan this aisle" → the glasses
- Shared store catalogs (the crowd-catalog payoff;
migration 505aisleask_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 sharedapplyOrderedSections— upsert-by-name so item→section mappings survive; bumps adopt_count + linkscatalog_id).applyOrderedSections+ purecleanSectionNames(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 506addslat/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-testedhaversineKm(results carrydistance_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 569aisleask_deals+ widens thecapturespurpose CHECK to addaisleask_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}→scanDealTagreads a shelf sale tag via gpt-4o-mini vision (sibling ofscanSectionSign) → acrowddeal keyed bynormalizeProductName(same key as the list + product→section map), and best-effort files anaisleask_dealcapture for provenance.GET /:storeId/deals→listDeals(drops deals pastvalid_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/dealscapability, 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). PureparseDealScangolden-tested. Plan: docs/product/AISLEASK_DEALS.md. Apply migration 569. - Public storefront SEO page (
migration 571addsaisleask_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 photo →generateStorefrontruns gpt-image-1 image-edit to repaint it painterly (keeps the real building recognizable), uploads to theaisleaskbucket, mints a slug, and flips the page on. A one-tap ✨ Generate (POST /:storeId/storefront/generate→generateStorefrontHero) 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); seecrosstalk/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-LDGroceryStore/Offerand renders live deals;public_deals_enabledtoggles viaupdateStore; slugs feedsitemap.ts. Plan: docs/product/AISLEASK_STORE_SEO.md. Apply migration 571. Every hero is versioned —migration 572addsstorefront_image_history(text[], newest-first, capped 12); each generate/upload writes a distinct Storage object, the dashboard shows a thumbnail row, andPOST /:storeId/storefront/selectrepoints the current version (no regen). Apply migration 572. - Spatial Store Map (
migration 570addsaisleask_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 akind(aisle/perimeter/facility) so numbered aisles (green) read distinctly from perimeter departments (amber) + facilities (sky); pointer-drag sets normalized 0..1 coords, batch-saved viaPOST /:storeId/sections/layout.POST /:storeId/sections/seed-perimeterone-taps the standard non-aisle zones;addSection+ the section route take an optionalkind;map_image_urlflows throughupdateStore/sanitizeStorePatch. Placed tiles are resizable + rotatable (migration 573addspos_w/pos_h/rotationon sections) — tap to select, drag the corner to resize or the ⟳ handle to rotate;saveSectionLayoutnow 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 510addsaddress/lat/lngtoaisleask_stores): a store now persists a free-text address + optional precise coordinates, so walker routing is accurate (the crosstalk route-planner, QS #519, takes exactlat,lngand 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).publishCatalognow defaults the catalog'slocation/lat/lngfrom 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). PurevalidCoord(now rejects null/''/undefined explicitly —Number(null)is 0, which would otherwise plant "no coordinate" at null-island (0,0)) +sanitizeStorePatchgolden-tested. Store-walker economy trio: this (accurate geo) + the route-planner (#519) + walker payouts (logged as an AisleAskideatask). Apply migration 510. - Recipe import ("one or more recipes, scanned or imported → one efficient route";
migration 504addsaisleask_items.source):POST /:storeId/import-recipe { url | text | image_base64 }→importRecipeextracts shopping ingredients (URL viafetchReadableText; pasted text; photo scan via gpt-4o-mini vision —feature_key: aisleask.recipe_import), normalizes (pure golden-testednormalizeIngredients: 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· recipesource. 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/checkAisleIteminhivejournal.ts,speakAisleNext(session, checkFirst, player)insession.ts(plain TTS like a lever ack, ducks under the soundtrack; "got it" checks the last item off + advances), and theaisleintent (next / got_it) inintent.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 564aisleask_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 achecked_at; there was no timed trip. A session is started (snapshotting the uncheckeditem_count), each pick decision is logged as an event (picked/substituted/unavailable, optional off-listitem_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.tscomputePickMetrics(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 whenduration_seconds ≤ 0(never Infinity/NaN),duration_seconds = round((completed_at − started_at)/1000)or 0 when incomplete/malformed/reversed. PURE — never callsDate.now()(a caller wanting a running duration passes a syntheticcompleted_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.ts—startPickSession(reusesassertOwnedStore),recordPickEvent(verifies session is caller's + open; apickedon-list item is also checked off the list),completePickSession(stampscompleted_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 (allauthenticateUser, 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).source∈self | doordash | pilot. Apply migration 564. Shopper frontend:/shop(below). - Shopper experience (the picking flow) —
/shop(): the phone-first surface a shopper actually uses to pick an order fast — the picking half of AisleAsk (the dashboard atShopClient.tsx/dashboard/aisleaskis the list-management half). Standalone-feeling (own minimal header, notDashboardLayout), reusesapiRequest+ 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 viaGET /:storeId/route; quick-addPOST /:storeId/items, paste a whole list viaPOST /: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 itpicked/ Subsubstituted+substitute_name/ Outunavailable— each firingPOST /pick-sessions/:id/events; a live elapsed timer (setIntervalcleared on unmount) + progressn / 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 565aisleask_orders+ nullableaisleask_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_idis nullable (null = the shopper's generic single-customer list, unchanged); items flow through the sameorderItemsByRouteengine (one merged walk,order_idrides along for per-customer attribution). Serviceaisleask-orders.ts:createOrder/listOrders/addOrderItems(reusesaddItem's section-guess path) /deleteOrder(FKON DELETE SET NULL→ items return to the generic list) /setOrderStatus(active|done|cancelled); per-order metricspickSessionByOrder(userId, sessionId)groups a trip's pick events by each event's item→order and reusescomputePickMetricsper order + a batch total. PURE core (golden-testedaisleask-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 areselect('*'), soorder_idsurfaces on/items+/routeadditively. Apply migration 565.- Batch shopping UI —
/shopgains 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 fromGET /pick-sessions/:id/by-order. Single-customer mode is byte-for-byte unchanged (a separatepicking && tripBatchbranch; zero orders → the original flat flow).
- Batch shopping UI —
- Shared product→section resolver (
migration 566aisleask_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.tsresolveSection(name)= ① shared-map lookup (free, bumpshit_count) → ② keywordguessSection(hit → upsertkeyword) → ③ LLM (completegpt-4o-mini, temp 0, constrained toSTANDARD_GROCERY_SECTIONS+ validated back → upsertllm) — 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-testednormalizeProductName. Wired intoaisleask.tsaddItem(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-storeaisleask_sectionsstill provide the walk order. Apply migration 566. - Delivery-leg routing (reuse, not reinvent) (
migration 567adds nullablecustomer_address/customer_lat/customer_lngtoaisleask_orders): the driver's drop route for a multi-customer batch — consumes QuickSites' liveroute-optimize(PorchHearth's nearest-neighbor+Haversine engine; contractcrosstalk/contracts/route-optimize.md), NOT our own routing. In-store section routing stays AisleAsk'sorderItemsByRoute; only the geographic leg reuses QS.aisleask-delivery.ts: purebuildRouteRequest(store→start, orders→stops — lat/lng passed through to skip geocode, else{label,address}, else skipped; ≤20-stop cap → overflow todropped);planDeliveryRoutefetchesQS_ROUTE_OPTIMIZE_URL(defaulthttps://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-testedaisleask-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), andmirrorVerdict(the cost-vs-stress gap →just_do_it/warn/do_now/let_it_drift). Tested bydownstream-curves.test.ts(8/8). - Service:
apps/backend/src/services/downstream.ts—inferCurve(LLMgpt-4o-miniturns a plain-language "thing I'm putting off" into a curve + cost/stress levels + downstream summary), item CRUD, andgetTriage(open items + live snapshots + relief summary). - Route:
apps/backend/src/routes/downstream.tsat/api/downstream—POST /infer,POST /,GET /triage,PATCH /:id,POST /:id/{resolve,snooze,dismiss}. Registered inindex.ts. - Migration:
supabase/migrations/481_downstream_items.sql—downstream_items(archetype + params + anchor + cost/stress levels + status +resolution_feedbackcalibration 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.tsrunDownstreamNudgeTick— 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 (toggledownstream_nudge, heartbeatdownstream_nudge_tick; wired in index.ts + cron-toggles.ts + system-health.ts). EndpointsGET /api/downstream/nudges+POST /nudges/:id/seen; surfaced as a banner on the dashboard. Migration482_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/nudgeslog). Public landing page atapps/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 (nextPomodoroPhasestate machine,phaseMinutes,composeFocusCue) tested bydownstream-focus.test.ts(16/16). At each boundary it speaks a calm cue in the user's own voice (reusessayInUserVoice+sendToUserpush) and logs it into thedownstream_nudgestraceability log (phasefocus_*, 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 viafocusEasedAnchor(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/:idroutes). Migration485_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 thedownstream_focuscron (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 inadvanceSession(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):estimatePhasereturns aphase(pre_wake/rising/peak/falling/trough), a modeledenergy(sine — trough at cycle boundaries, peak mid-cycle), and a deliberately-modestconfidencethat decays across the day;phaseSuggestionmaps the phase to a calm suggestion (peak → take the hard item; trough → take the break). Pure module, tested bydownstream-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 ondownstream_prefs(migration486: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 cadence —rhythmTunedDurations(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 instartSessionwhen the client requests it viarhythmTune(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 flipssourcetobehavioral. Two signal sources: self-report Low/Steady/High taps (recordEnergyTap+POST /api/downstream/energy { level: 1..5 }, stored indownstream_energy_taps, migration487, weight 1.0) and behavioral —focusEnergyTapsderives an energy signal from recent completed focus sessions (sustained focus → energy; more rounds = stronger; weight 0.5).getRhythmmerges 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 viaGET /api/downstream/rhythm?epm=Nand 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 pick —recommendForPhase(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 thegetTriageresponse aspick+phaseand shown as a "Right now → <item>" line (with Focus ▶) in the rhythm strip. Open-science overlay —aggregateEnergyCurve(pure, tested) buckets energy taps into an average-energy-by-hour curve;getEnergyCurvesreturns 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 512downstream_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, withauthor). Rendered viasayInUserVoice(jq-voice.ts→ thejq_voice_clipscache, 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 coredownstream-mantras-core.ts:normalizeMantra(≤280) +weavePlaylist(peppers a quote after every N mantras, cycling, leftovers appended — golden-tested, 5 cases). Servicedownstream-mantras.ts: CRUD +getMantraPlaylist(active rows → weave → render each in-voice; optionaldaily=1folds in the rotating ZenQuotes quote viagetDailyQuote; throwsMantraVoiceError→ 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 backdroppublic/painterly/mantras-candle-dawn.webp). Dashboard host/dashboard/mantras(client) composes the panel + two extras frommantra-content.ts: starter sets (MantraSets.tsx— 7 themed packs bulk-adopted via repeatedPOST /api/downstream/mantras, panel reloads via areloadSignalprop) 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/featurescatalog. Glasses: say "play my mantras" →pulls the in-voice playlist andfetchMantrasplayMantras(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); intentmantras(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 stockVOICE_BANKpreset —getMantraPlaylistresolves the stored pref (downstream_prefs.mantra_voice_source/mantra_voice_id) and dispatchesown→sayInUserVoice,stock→sayInVoiceId. The stock path is whitelist-only (setMantraVoicePref/isStockVoiceIdreject any non-VOICE_BANKid) 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 onMantrasPanel; endpointsGET/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 ingestion —
importFromUrl/storeUploadincreator-audio-library.tsscrape a Suno share page and re-host the MP3 (surviving CDN rotation); the soundtracks layer keeps only a thin reference row. Pure coredownstream-soundtracks-core.ts—moodForPhase(the ultradian bridge: peak→energize, rising→focus, falling/trough→calm, pre_wake→ambient) +pickForMood(exact-mood match → first loopable → first; honest fallback chain), tested bydownstream-soundtracks-core.test.ts(10/10). Servicedownstream-soundtracks.ts— list (owned + curated), import/upload, update, archive, andgetSoundtrackForPhase(readsgetRhythm→ mood → pick). Endpoints under/api/downstream/soundtracks:GET /,GET /for-phase,POST /import,POST /upload(multipart),PATCH /:id,DELETE /:id(registered before the generic/:idroutes). Migration489_downstream_soundtracks.sql—downstream_soundtracks(nullableuser_id= curated/global,moodCHECK,loopable, re-hostedaudio_url). RLS on, backend-only. Glasses looperapps/glasses/src/soundtrack.ts—createSoundtrackPlayer(session)loopssession.audio.playAudio({ audioUrl, volume: 0.22 }), re-fetching each loop so the pick stays phase-adaptive, and ducks under every nudge viaduckFor()(wired intospeakNudgeinsession.ts).nextReplayDelayMs(pure, tested) makes the loop correct whetherplayAudioresolves at track-end OR immediately. Voice control: "play/stop music" via the keyword floor inintent.ts; auto-start behindSOUNDTRACK_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.tsat/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'splayAudioMIX or INTERRUPT the bed (decides whetherduckFor's best-effortstopAudiois 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(migration490, nullable, 1–3650) makes an item a repeating obligation. On resolve,resolveItemstill 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 viarecurrenceDayson 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 puredownstream-calibration-core.tsshapeCalibration, 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 (updateItemnow takesanchorDaysAgoto 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/history→listResolved, 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.ts—interventionEffect(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), andsummarizeLevers(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 bylevers-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.tsat/api/levers—GET /,POST /levels,POST /levels/:id/readings,POST /interventions. Registered inindex.ts.POST /interventionsalso acceptstargetLevelName(a SPOKEN level name — the glasses voice path): resolved viamatchLevelByNamein levers-core (exact → prefix → bidirectional substring), defaulting to the user's only active level; the response includestarget_level_namefor the spoken ack. - Voice logging (Downstream Glasses): say "log a 20-minute walk for energy" →
apps/glasses/src/intent.tsparseLeverUtterance(explicitlog …/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) →logLeverinhivejournal.ts→speakLeverLoginsession.tsacks in-ear via plain TTS (a system receipt, not a JQ voice render), ducking under a running soundtrack. - Migration:
supabase/migrations/488_levers.sql—levers_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.comANDgraphene.fm. Pre-#509 the FB Pixel fired on every host that hadNEXT_PUBLIC_FB_PIXEL_IDset, mixing two distinct audiences (journaling humans + audio-fiction listeners) into one cohort and degrading Meta's lookalike-audience suggestions for both surfaces. New optionalNEXT_PUBLIC_FB_PIXEL_HOSTNAMEenv var: when set, the pixel only fires whenwindow.location.hostname.endsWith(<value>). Unset preserves pre-#509 global behavior. Set tographene.fmfor a Graphene-only pixel; set tohivejournal.comfor 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 therecordHeartbeatpattern so delivery failures land inservice_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-hook—apps/backend/src/routes/auth-email-hook.ts. Mounted BEFOREexpress.json()inindex.tsso the raw body is available for Standard Webhooks HMAC verification (usingstandardwebhookspackage). - 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.tsonly (Graphene confirmation email). Phase 2: hivejournal + writecafe brands, recovery/magiclink/email_change actions. - Frontend signup wiring:
apps/frontend/src/app/auth/signup/page.tsxpassesoptions.data.brandderived fromdetectBrandSlug()(branded-surface.ts) so the hook knows which brand to render. - Env vars (per backend env):
SUPABASE_AUTH_EMAIL_HOOK_SECRET(Standard Webhooks secret, formatv1,whsec_…),RESEND_API_KEY(already set, shared with DreamPro emails),RESEND_FROM_ADDRESS_GRAPHENE(optional, defaults toGraphene <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.fmmust be added as a verified sender domain at Resend (DNS records on the brand's domain).hivejournal.comis 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-signedmintUnsubToken/verifyUnsubTokenhelpers (token =userId || sha256(userId + secret)[:8], base64url-encoded). - Cron: 1h interval, registered in
index.tsparallel to the daily-prompt cron. Heartbeat namegraphene_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— createsgraphene_plus_drip_log (user_id, step, sent_at)table withunique (user_id, step)constraint for idempotency, plusprofiles.graphene_plus_drip_opted_out_atopt-out column. - Gates per step: (1) brand === 'graphene', (2) no active
graphene_plussubscription, (3)graphene_plus_drip_opted_out_atIS NULL, (4) no row ingraphene_plus_drip_logfor 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 toSUPABASE_SERVICE_ROLE_KEYif unset).RESEND_FROM_ADDRESS_GRAPHENE(same as Branded Auth Emails).PUBLIC_BACKEND_URLfor 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.tsparallel 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 namelovio_waitlist_drip_tick. - Unsubscribe route:
GET/POST /api/lovio-waitlist-drip/unsubscribe?token=…—apps/backend/src/routes/lovio-waitlist-drip.ts. Flipslovio_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— createslovio_waitlist_drip_log (waitlist_id, step, sent_at)withunique (waitlist_id, step)for idempotency. - Env vars:
LOVIO_WAITLIST_DRIP_UNSUB_SECRET(HMAC key — falls back toSUPABASE_SERVICE_ROLE_KEYif unset).RESEND_FROM_ADDRESS_LOVIO(same as Branded Auth Emails).PUBLIC_BACKEND_URLfor building unsubscribe URLs.LOVIO_REPLY_TO(optional — defaults tosandon@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/previewreturns{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/:stepsends the rendered email toreq.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_pipeline — no 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/currentcoil.ts— toroid/solenoid inductance (Wheeler's, Al-value), SRF, distributed capacitance, DC resistance. Includes FT37-43/50/82/114 cores with Al valuesimpedance.ts— Z(f) sweep with magnitude + phase, logarithmic frequency axiselectrolysis.ts— Faraday baseline, voltage efficiency, overall efficiency, anomaly flagging when measured gas exceeds Faraday predictionpulsed.ts— back-EMF peak, inductor energy, recovery efficiency, pulse-edge bandwidth, time-domain waveformplasma.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'spattern_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.sqladdscalculator_inputs,calculator_outputs,measured_values,delta_valuesJSONB todream_replications+predicted_valuesJSONB todreams.PATCH /api/dreampro/replications/:id/measurementsaccepts 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.tsruns 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 aspredicted_values),GET /api/dreampro/templates/:id/confirm-invitation(public, returns the golden spec + confirmation progress toward 50). FrontendExperimentOptimizer.tsxrenders: "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.tsxmounted on research-grade template pages. Backend:GET /api/dreampro/templates/:id/measurement-statsaggregates 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.ts—generateDailyComic()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.ts—GET /api/daily-comic/today(returns all today's comics + selected),POST /api/daily-comic/generate(accepts optionalrecipe_id+user_idoverride for admins),GET /api/daily-comic/history(acceptsinclude_ai=true),POST /api/daily-comic/select/:comicId. - Comic recipes:
Migration 084—comic_recipestable. 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 082—buzz_publicon profiles,comic_followstable. Users toggle public, others follow. Feed shows followed users' comics. Public profile at/buzz/[userId]. - Multiple comics per day:
selectedboolean column, users pick which is "today's comic". Generate another button always available. - External embed (QuickSites
daily_artifactblock) (migration 503): opt-in, off by default, SEPARATE consent frombuzz_public—profiles.buzz_quicksites_optin+ an opaqueprofiles.buzz_embed_token(so third-party sites never get the account id). PublicGET /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 indaily-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
storyIdprop, it persistscurrentIndextohj:story-progress:<storyId>:v1in 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.ts—GET /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.sql—direct_messagestable with RLS, conversation index.
Stream Filter Panel + Entry Content Filter Strip
Two complementary filter surfaces share the same backend.
StreamFilterPanel — meta 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/journalacceptsai_only,ai_dreams_only,personality,age_group,note_typeparams. - Mounted on both
/dashboard(top-right inside the page chrome) and/dashboard/stream(floating bottom-left bubble).
EntryContentFilterStrip — content 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¬ebook_id=…&tag=…&q=…fragment for/api/journal). - Backend:
/api/journalacceptsnotebook_id,tag,person,mood,since_days,qparams. - 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: 24pxviaChatbot.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-8so right = 56 − half-width at each breakpoint.style={{ bottom: 'calc(7rem + env(safe-area-inset-bottom))' }}inQuickEntryFab.tsx. z-95. - ActionButton (bottom 12 rem, 19 px default → 56 px hover) — inline
right: 'calc(56px - (56px / 6))'default; hover/leave handlers swaprightto'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_settingsunder keyai_comic_throttle(no new schema needed — reuses migration 009's table). Serviceapps/backend/src/services/admin-throttles.ts—getComicThrottle()(60s in-memory cache so the persona-sim hot path isn't hammering Supabase),setComicThrottle(), andshouldGenerateComic(path)which the two comic generation sites consult: the post-entry path insidegenerateJournalEntry()(~1h after every AI entry) and the daily-buzz path insiderunPersonaSimulation(). Throttle shape:{ mode: 'normal' | 'reduced' | 'paused', reduction_factor: 0-1, post_entry_enabled: bool, daily_buzz_enabled: bool }. Inreducedmode each scheduled comic only fires with probabilityreduction_factor. The two*_enabledflags are independent kill switches per path. Routes:GET /api/ai-personas/throttles/comic,PATCH /api/ai-personas/throttles/comic. UI:ComicThrottleControls.tsxrenders at the top of/dashboard/admin/ai-personas— mode picker, reduction-factor slider (only shown inreducedmode), per-path checkboxes, last-changed timestamp. Per-personaenable_daily_buzzstill applies on top — this is a global gate. Defaultmode='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 byrunPersonaBrowserSweep(1-minute tick, heartbeat namepersona_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 topersona_browser_steps(audit trail) and emits astepevent on the SSE stream. - Phase 2 — autonomous (
mode='autonomous'): LLM drives the same session via tool-use loop. Actionpersona_browser.autonomous_browseregistered in the persona-actions framework — tools:navigate/scroll/click_link/back/extract_text/done. Ondone(or when caps trip) writes a 3–6-sentence journal entry citing the pages read. Caps in(persona_browse_throttlegetPersonaBrowseThrottle()):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'sextractForm()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.tomlprovisions chromium runtime libs (fontconfig/nss/atk/...) for Railway. Local dev requirescd apps/backend && npm installto pull the chromium binary into~/.cache/ms-playwright. The browser-pool import inpool.tsis 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/browsinghas two strips: a live "Reading right now" ticker at the top (10s poll onGET /api/persona-browser/public/live— pulls in-memorylistSessions()filtered tomode='autonomous'+status='live'+is_activepersonas, 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 closedautonomoussessions as cards — persona name, goal, URL trail, mood, journal entry produced). Read endpointGET /api/persona-browser/public/recent(no auth, 60s cache) joinspersona_browser_sessions→journal_entriesvia the newjournal_entry_idcolumn (migration 317). Only surfaces sessions where the persona isis_active,mode='autonomous',status='closed', andjournal_entry_id IS NOT NULL— manual debugging sessions andsubmission_helperruns 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 UI —
BrowseThrottleControls.tsxmounts at the top of/dashboard/admin/ai-personasnext to ComicThrottleControls. Mode picker (normal/reduced/paused), reduction-factor slider whenreduced, and three per-session cap fields (max steps / seconds / cents). Endpoints:GET /api/ai-personas/throttles/browse,PATCH /api/ai-personas/throttles/browse.pausedis the kill switch — flips the autonomous-browse cron off without a redeploy; manual autopilot from the admin viewport also refuses to start with apausedthrottle. - Persona scrapbook (
migration 318+services/persona-actions/browse-action.tspickScrapbookEntry()): after every autonomous browse session, a smallgpt-4o-minicall (~$0.001) picks the single URL most worth keeping plus a one-line "why this caught my eye" in the persona's voice, writes topersona_scrapbook_entries. URL is trust-but-verify against the session's actualpages_visitedso a hallucinated link can't sneak in. Public page/write-cafe/personas/[id]/scrapbookaggregates every entry per persona (newest first, ~60 per fetch), backed byGET /api/persona-browser/public/scrapbook?persona_id=X. Each card on/write-cafe/browsingnow also surfaces a📎 kept hostnameindicator 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.tstryWriterTargetedBrowse()+ migration 318'spersona_browser_sessions.season_id+ migration 319'sepisode_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_idboth stamp onto the session row + mirror into the scrapbook entry. Surface — "What I've been reading" widget on/writers/[handle](page.tsxWriterReadingWidget): 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 awhile writing ch. N of <show>chip when an episode is linked. Backed byGET /api/persona-browser/public/writer/:userId/recent(4 sessions, 2min cache). Also:GET /api/persona-browser/public/season/:id/recentfor per-season aggregation. - Scrapbook curation — owner-or-super-admin can delete scrapbook entries via
DELETE /api/persona-browser/scrapbook/:id(auth gate: viewer'sauth.users.idmatchesai_personas.user_id, ORprofiles.role='super_admin'). TheGET /public/scrapbookendpoint reads the bearer token and returnscan_curate: booleanso/write-cafe/personas/[id]/scrapbookonly shows the 🗑 Remove button + the amber "you can curate" hint to authorized viewers. Anonymous reads still get the scrapbook (public-cached); authorized reads switch toCache-Control: private, no-storeso 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_tickheartbeat), 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_attoggle 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/browsinglinks 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 byGET /api/persona-browser/public/session/:id(no auth, 5min cache, sameautonomous+closed+is_active+has-journal-entry filters as/public/recent; 404 for anything else). Step display switches onkind— 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 + hascommon_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 curatedstart_urlchosen from a topic→URLs map inpersona-browse-cron.ts(matches likewriting→ 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 viaPERSONA_BROWSE_CRON_ENABLED=trueso deploys never surprise-bill on LLM + Playwright. Knobs:PERSONA_BROWSE_CRON_INTERVAL_HOURS(default 1),PERSONA_BROWSE_CRON_PERSONA_COOLDOWN_DAYS(default 7). Throttle frompersona_browse_throttle(admin-throttles.ts) still applies on every session — setmode='paused'to kill switch without redeploy. Heartbeat:persona_browse_cron_tick.
- Phase 1 — manual (
- 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— theDEFAULT_POSE_KINDSset). Generated via DALL-E 3 with a style anchor in the prompt, then re-hosted to Supabase storage atseason-assets/persona-poses/<persona_id>/<kind>.pngso 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=1to re-roll);DELETE /api/ai-personas/:id/poses/:poseIddrops a pose so the admin can re-roll without dupes. Frontend:PersonaPoseAvataris 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-personascallsgenerate-librarywith a confirmation prompt + cost note. Auto-trigger on persona create:createAIPersona()fires a fire-and-forget background pose-library generation whenAI_PERSONA_AUTO_POSES_ON_CREATE=true(default off; opt-in so bulk persona spawns don't surprise-bill). Per-kind errors are recorded toservice_errorsvia 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 flagscan_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; addstarget_labeltext column for human-readable target names since error groups have no UUID;metadata jsonbfor 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. Forflag_crondecisions, target_id is null and the heartbeat name lives intarget_label. - Backend:
services/infra-monitor.ts— snapshot/assign/release/recordMonitorDecision.routes/infra-monitor.ts—GET /,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 withtarget_labelrendered as a code chip. Linked from FeatureMenu as 🛠️ Infrastructure Monitor. - Phase 2 — daily brief generator:
services/infra-monitor-brief.tsgroups the last 7 days ofservice_errorsrows by(service, context, message), filters out currently-muted groups + groups with zero unresolved rows, sorts by severity → count → recency, and surfaces the top 15 plushas_mute_history(the tuple was ever muted) andis_stale(no occurrence in 48h+) flags. Pullssystem_heartbeatsand 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_crontarget_label must match an unhealthy cron name). Writes onebriefdecision 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 intorunContentManagerBriefLoop, gated byINFRA_MONITOR_ENABLED=true. Heartbeat metadata extendscontent_manager_brief_tickwithinfra_monitor_status+infra_monitor_recommendation_count. - Phase 3 — autonomous executor + manual execute/reverse:
services/infra-monitor-executor.tsimplements the three action paths.executeMonitorDecision(decisionId, byUserId, autoExecuted?)switches onaction_kind:mute_group— fetches the representativeservice_errorsrow 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 newservice_error_mutesrow withmuted_until = now + 7d(overridable viadecision.metadata.mute_days), and stores{ muted_until, mute_days, muted_tuple }in the decision's metadata.resolve_group— fetches the tuple, finds all unresolvedservice_errorsrows matching it, stampsresolved_at + resolved_by_user_idon 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; stampingexecuted_atrecords "human eyes have seen this." Never auto-executed.
runAutonomousMonitorActions()drains pendingmute_group+resolve_grouprecommendations (NOTflag_cron) within the role's scope flags anddaily_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'shas_mute_history/is_stalehints. Wired intorunContentManagerBriefLoopright after the brief generator.reverseMonitorDecision()undoes execution: mute → delete the mute row by tuple; resolve → clearresolved_aton the exactresolved_idswe 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) andPOST /api/admin/infra-monitor/decisions/:id/reverse(undo or dismiss-without-acting). Frontend BriefPanel surfaces per-recommendation Execute / Dismiss buttons;flag_cronrows 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_athere means "super-admin manually acted on it",auto_executedis always false),marketing_advisor_goals(growth-side metrics likeweekly_signups,graphene_plus_conversion).- Backend:
services/marketing-advisor.ts— snapshot/assign/release/recordAdvisorDecision.routes/marketing-advisor.ts—GET /,POST /assign,POST /release,POST /notes,POST/PATCH/DELETE /goals,POST /decisions/:id/execute(super-admin marks a growth_bet as acted-on). NoPATCH /roleendpoint (no scope flags to update). Mounted at/api/admin/marketing-advisor. - Frontend:
/dashboard/admin/marketing-advisor— RoleCard (rose accent),AdvisoryOnlyBannerexplaining why no autonomy, DirectivesPanel, GoalsPanel, DecisionsPanel with "Mark acted on it" buttons ongrowth_betrows 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_betdecisions 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_betrecommendations. Each bet'starget_idis 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 byMARKETING_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 extendscontent_manager_brief_tickwithmarketing_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 inlocalStorage(keyhj: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'sITEMSarray — 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(orbash 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— scanssupabase/migrations/forcreate policystatements lacking a matchingdrop policy if exists ... on <table>guard. Postgres has nocreate policy if not existsuntil PG15 (and Supabase doesn't reliably support that syntax across versions), so re-applying a barecreate policyagainst a database that already has the policy errors with42710and 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 itCREATEs + columns itADDs 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. ReadsSUPABASE_URL/SUPABASE_SERVICE_ROLE_KEYfrom env orapps/backend/.env. Usage:npm run audit:schema. NOT part of the PR hard-gatenpm 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 acrossapps/backend/src/services/andapps/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 zerorecordServiceErrorcalls as candidates for manual silent-failure inspection. Current state: 25/241 files userecordServiceError; 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) inapps/backend/src/routes/for an auth middleware in its chain. Detects both per-route middleware AND file-levelrouter.use(authMiddleware)gates. Allowlists known public endpoints (webhooks, cron triggers, unsubscribe links, public form submissions). Caveat: can't see inlinereq.headers.authorizationparsing — 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 everycreate tablefor a correspondingalter table … enable row level securityanywhere 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 eachsetInterval(callback_name, …)inapps/backend/src/index.ts, finds the latest declaration ofcallback_nameand reports any callback whose body doesn't mentionrecordHeartbeatorwithHeartbeat. A loop without a heartbeat is invisible: when it wedges,/dashboard/admin/system-healthkeeps 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— scansapps/backend/src/forreplicate.run(/.images.generate(call sites (image/video/audio gen, which never route throughcomplete()) and reports which files do NOT callrecordGenerationCost— 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_HEARTBEATSset inservices/system-health.ts(May 2026, PR #198) — names that reusesystem_heartbeatsfor 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 tosystem_heartbeatsfor 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 underlyingtable.columnorENV_VAR_NAMEas a small mono hint. Why static lists rather than auto-discovery: ENV_CHECKS / MIGRATION_CHECKS are hand-curated additions inroutes/admin.tsso 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) andhttps://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_flagsis flattened into ajsonbcolumn viajsonb_build_objectper UNION arm (so the heterogeneous can_* booleans live under one column). Marketing Advisor's row hasscope_flags = '{}'::jsonbanddaily_action_budget = NULL(it has neither in its underlying table).v_platform_role_decisions— every decision across all 5 roles.target_labelandmetadatacome frominfra_monitor_decisionsonly (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 therole_kindtag.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.ts—GET /api/admin/rolesreturns aseatsarray with one entry per role kind (vacant seats included). For each seat: holder (hydrated fromai_personasorauth.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-rolesnot/rolesbecause 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/decisionsreads fromv_platform_role_decisionswith optionalrole_kind+action_kindfilters 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/goalsreads fromv_platform_role_goalswith optionalrole_kind+statusfilters; 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. Hydratesset_by_user_idto display_name in batch. UI surfaces urgency cues:days_until_targetcolored rose for overdue, amber for ≤3 days, gray otherwise; progress bar whenprogress_metric+progress_value/progress_targetare 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/invitesreads fromv_platform_role_invites, computes state per row from(used_at, used_by_user_id, expires_at), returns the absolute claim URL via arole_kind → kebab-casepath map (platform_director → platform-director, etc.). Hydratesminted_by_user_id+used_by_user_idin 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 viaheld_by_persona_id | held_by_user_idwithexactly_one_reader_lead_holdercheck. Scope flags:can_rotate_graphene_snippet/can_feature_cafe_books/can_feature_critiques. All default false;daily_action_budgetdefaults 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. Sameexecuted_at/reversed_at/auto_executedlifecycle as CM.reader_engagement_goals— engagement-side goals (e.g. "lift weekly reads per featured season by 25%"). Same shape ascontent_manager_goals.
- Backend:
services/reader-engagement.ts—getReaderEngagementSnapshot()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.ts—GET /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-engagementinindex.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.tsgathers signals (top seasons by 7d views/reads/follows fromseason_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 onebriefdecision + 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*3per 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=1to bypass idempotency). Cron: bundled intorunContentManagerBriefLoopinapps/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 byREADER_ENGAGEMENT_ENABLED=true. Heartbeat metadata extendscontent_manager_brief_tickwithreader_lead_status+reader_lead_recommendation_count. - Phase 3 — autonomous executor + manual execute/reverse:
services/reader-engagement-executor.tsimplements all five action paths.executeReaderLeadDecision()switches onaction_kind:rotate_snippet— captures priorgraphene_featured_snippetstate 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 (thereader_engagement_decisionstable has nometadata jsonbcolumn unlikeinfra_monitor_decisions).feature_cafe_book/unfeature_cafe_book— flipscafe_books.is_featured.feature_critique/unfeature_critique— flipsstory_critiques.is_featured.
runAutonomousReaderLeadActions()drains pending recommendations within scope flags + budget. Adds a safety re-check forrotate_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 sharecan_feature_cafe_books; all critique actions sharecan_feature_critiques. Wired intorunContentManagerBriefLoopright after the Reader Lead brief generator.reverseReaderLeadDecision()undoes each path. Forrotate_snippet, parses the captured prev_snippet from the rationale tail and upserts the singleton back to that state (or clearsseason_idif 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 existingGET /api/admin/platform-director+GET /api/admin/content-managersnapshots 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_managerdecisions 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/notesmirrors the pre-existingPOST /api/admin/content-manager/notes— both write anotedecision 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.allof 5 snapshot endpoints.NOTES_ENDPOINT/BRIEF_ENDPOINT/ROLE_PAGE/COMPOSER_PLACEHOLDERmaps keyed byRoleKind. GenericbuildSimpleTimeline()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_TABLEextended 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 seatbutton, and "Draft new" with editable display_name/MBTI/zodiac/bio/writing_style/topics/backstory +Create + Assign to seatbutton.- Backend:
services/plan-role-recruit.tsbuilds the prompt + parses the JSON response (mirrors plan-persona-season.ts conventions).routes/role-recruit.tsexposesPOST /api/admin/role-recruit/scan,POST /promote,POST /create-and-assign. Scan excludes personas already filling another role-seat (queries all 5 role tables forheld_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
imported in two contexts: (1) inside each ChatPanel onRoleRecruitPanel/dashboard/admin/ai-overviewand (2) insideRoleSeatVacantPanelon 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:
assignContentManagerhard-requiresis_platform_writer = trueon the persona. The recruit route auto-flips that flag before assigning when the target role iscontent_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.tsrecovers from that mode by running a temperature-1.0 rename pass on any persona matchingJordan K%. - Admin steering: optional 1000-char textarea in the pre-scan view; passed as
customInstructionson 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 Ndif >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.handleis hydrated via batchedprofiles.usernamefetch in/scan. - Auto-first-brief on fill: after a successful promote or create-and-assign, the panel fires the new seat's
/briefendpoint 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
notedecision row to that role's decisions table withrationalestarting"Filled via AI Recruiter — ..."+ LLM reasoning (for promote) orwhy_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 isnote 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=1filter (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/errorsinstead of just hitting Railway logs. - Re-recruit on filled seats (
RoleRecruitModalcomponent): 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 thatpreventDefaults the wrapping Link), filled cards via a discreet "🎯 Re-recruit" text button.RECRUIT_KIND_BY_SEATmap handles theplatform_director→directorschema-vs-API mismatch. - "🎯 via Recruiter" chip on holder card: each per-role dashboard's
RoleHolderCardrenders a cyan chip next to the AI/Human badge when the current seat was filled via the recruiter. Computed client-side viaisFilledViaRecruiter()(inaudit-utils.ts) — scans the snapshot'sdecisionsarray for anoterow withrationalestarting"Filled via AI Recruiter"andtarget_idmatching 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.
- Backend:
- 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/roleson 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 optionalholder.persona_idfield on/api/admin/rolesseat summaries; frontend builds apersona_id → seat_metamap 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 ofcontent_manager_role(swappable holder viaheld_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 viareply_to_message_id.
- Backend:
services/platform-director.ts—getDirectorSnapshot()(role + hydrated holder + recent messages),assignDirector({ personaId | userId }),releaseDirector(),sendDirectorMessageToContentManager(),getRecentMessagesForContentManager()(used by the CM brief generator).routes/platform-director.ts—GET /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 237addsplatform_director_decisions+platform_director_goals+services/platform-director-brief.ts+ cron tick wired intoindex.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 aplatform_director_decisionsrow withaction_kind='brief'. Ifmessage_to_manageris produced AND the role hascan_send_to_manager=trueAND the daily_action_budget has remaining capacity, the message is POSTed toplatform_role_messagesaddressed 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 advisorymessage_to_managerdecision (executed_at=null) so the super-admin can see what the Director wanted to send. Manual trigger:POST /api/admin/platform-director/brief(?force=1to override the 22h idempotency window). Cron gate:PLATFORM_DIRECTOR_ENABLED=true. Cost: ~$0.001/day. Heartbeat metadata (director_status,director_message_sent) shares the existingcontent_manager_brief_tickheartbeat 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_directorfield in its JSON output. When present, it's posted toplatform_role_messages(from_kind='content_manager', to_kind='director', to_role_id=current director's role) AND mirrored as aresponse_to_directordecision 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. Togglecan_send_to_manager(AI auto-sends its brief's message_to_manager to chat) +can_set_manager_goals(forward-prep; wiring is in place). Setdaily_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") becomenotedecisions that also flow into the brief.PATCH /roleaudit-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 onroutes/platform-director.ts+/invite/platform-director/[token]/page.tsxpublic 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/invitesmints;GET /api/platform-director-invites/:tokenis the unauthenticated landing-page lookup (renders 410 on used/expired);POST /api/platform-director-invites/:token/claimrequires 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 arole_transferreddecision. Goals + decisions + scope/budget all carry over via FK torole_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 tablescontent_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 flagscan_promote / can_scaffold / can_review / can_approve / can_publishall default false;daily_action_budgetdefaults 0 — Phase 1 is advisory-only even with scope on.content_manager_decisions— every action the role takes/notes, withexecuted_at(null = advisory),reversed_at(super-admin can revert),auto_executed(Phase 3 cron flag),target_kind/target_idpolymorphic 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.tsexposesgetContentManagerSnapshot()(role + hydrated holder + recent decisions + goals),assignContentManager({ personaId|userId, assignedByUserId })(replaces any existing assignment, audit-logged as arole_transferreddecision),releaseContentManager()(vacates seat, audit-logged), andrecordManagerDecision()(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 viarequireSuperAdmin. - Frontend:
/dashboard/admin/content-managershows 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 pushto 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 inapps/backend/src/index.ts+ manual triggerPOST /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 acontent_manager_decisionsrow withaction_kind='brief'+executed_at=null— advisory only, nothing autonomous yet. Cost ~$0.001/day on gpt-4o-mini. Cron gated byCONTENT_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.tsJSON-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 owncontent_manager_decisionsrow withexecuted_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 flipsis_platform_writer, scaffold runsplanPersonaSeason+ inserts the season, review markschanges_requestedwith notes); reverse is the per-action inverse. Every execute stampsexecuted_at+auto_executed=false. - Phase 5 — Human handoff via invite/claim (
migration 235+routes/content-manager-invites.ts+ mint/list endpoints onroutes/content-manager.ts+/invite/content-manager/[token]/page.tsxpublic landing + InvitePanel on the dashboard): closes the "swap an AI for a human over time" vision the role schema was built around. Newcontent_manager_invitestable holds one-time tokens bound to the role; super-admin mints viaPOST /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/claimrequires 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 fromheld_by_persona_idtoheld_by_user_id, then an auditrole_transferreddecision lands with the rationale referencing the invite. Decisions + goals carry over via FK torole_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 incontent_manager_goalswith status ∈(active, met, missed, paused). The brief generator'srecent_admin_notescontext pulls the 5 most recent admin-authored notes from the decisions log (filtered toaction_kind='note'ANDby_user_id IS NOT NULLAND not a scope-change audit). NewPOST /api/admin/content-manager/notesendpoint accepts a free-form body, inserts as a note decision withexecuted_atstamped 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()inservices/content-manager-executor.ts+PATCH /api/admin/content-manager/role+ editable ScopePanel + cron-tick wiring inindex.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 matchingcan_*scope flag AND the daily budget has remaining capacity (countsauto_executed=truedecisions in the last 24h). Matching recommendations get executed withauto_executed=true; everything else stays advisory. The persona'suser_id(every AI persona has a real auth.users row) is used as the audit attribution for downstream stamps likereviewed_by. Admin controls the scope + budget via the now-editable ScopePanel: toggle any ofcan_promote / can_scaffold / can_review / can_approve / can_publish+ setdaily_action_budget(0-100). Saving firesPATCH /rolewhich writes the diff + audit-logs the scope change as anotedecision. Safety: budget=0 keeps autonomous mode paused even when scopes are ON (with an in-card warning); the heartbeat metadata surfacesautonomous_attempted / executed / failed / scope_skipped / budget_skippedso 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 230addsis_platform_writer boolean+ partial index. Independent ofis_active(a persona can sim-write journal entries without being a platform writer, and vice versa). Admin toggle on/dashboard/admin/ai-personas—✦ Promote / ✦ Demotebutton +✦ Platform Writerchip 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) writesstory_seasons(owner_user_id = persona.user_id, status='draft', review_state='unreviewed') + 9story_episodesrows (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 viaPromise.allSettled(per-chapter failure isolation). Sets triggered=true on success. - Phase 2 (approval) — Review pipeline.
Migration 231addsreview_stateenum (unreviewed/changes_requested/approved) + reviewer/timestamp/notes + partial index for the review queue. Persona seasons start asunreviewed; the publish endpoint (PATCH /api/story-seasons/:id/published) blocks until state moves toapproved. 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 232addsinvite_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:🎫 Invitebutton 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 innext. 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=1is 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 byWRITER_FLEET_ENABLED=true) picks one eligible platform-writer persona and scaffolds a new season. Two cooldowns: fleet-wide (WRITER_FLEET_MIN_INTERVAL_HOURS=24default — only one new scaffold per fleet per day) + per-persona (WRITER_FLEET_PERSONA_COOLDOWN_DAYS=7default — 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-seasonroute + CM autonomous executor) hydrate three best-effort signals fromservices/season-drop-off.tsand pass them intoplanPersonaSeason(). (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 (scoredreads + 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
/writersalongside human writers as soon as one of their seasons reachesis_published_to_graphene=true. Three pieces wire this up: (1)createAIPersona()inservices/ai-personas.tsnow auto-assigns a slug-formusernameon the persona's profile viapickAvailableUsername()(display-name → kebab-case, collision-suffixed) so the/api/writers/publicfilter (which requiresusername IS NOT NULL) lets persona profiles through. (2) That same/publicendpoint now returns anis_ai_personaboolean per writer (computed by checkingai_personasrows 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 inPATCH /api/story-seasons/:id/reviewnow auto-flipsis_published_to_graphene=true(andstatus: 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_atis non-null, the 🤖 chip drops, and the writer reads as fully human. Backfill for existing personas:POST /api/ai-personas/backfill-backstoriesnow also assignsusernameto any persona profile missing one (returnsusernames_assignedcount alongsideprofiles_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-toggledacceleration_mode_enabledflag (migration 249,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:writer_fleet_settingsPOST /api/story-seasons/admin/writer-fleet/acceleration-mode. Single source of truth for cooldowns inconstants/writer-fleet.tsso cron + status display can't drift. - Phase 5b — Archive proposed seasons. Migration 250 adds
+story_seasons.archived_atarchived_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=truereturns 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.tsexplicitly 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 withcascade_fired: string[]listing what kicked off. - Per-persona quality chip on AI Personas list. Each platform-writer row on
/dashboard/admin/ai-personasshowsN✓ · N? · N📦(approved / pending / archived). Backed byGET /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_stateinto 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.ts—browseAndExtract(reusespersona_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.tsruns our own personas over 7 HJ surfaces; findings curated at/dashboard/admin/persona-testing→ the public case study/persona-testing(renders onlyis_publicrows). 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-gatedGET /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=10granted every month (funnel-first free tier); Stripe packs 25/$9 · 100/$29 · 300/$69 (webhookkind='persona_test_credit_purchase'inroutes/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 inlib/persona-testing-compare.ts; registered incompare-registry.tswith a 30-day (monthly) refresh cadence. - Routes (mounted
/api/persona-testing): publicGET /showcase,GET /report/:token,GET /credits/packages; owner-authedPOST /request,POST /request/:id/verify,GET /request/:id,GET /requests,GET /credits,POST /credits/checkout; super-adminGET /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): addsprofiles.requires_publish_review boolean(default false). Independent ofis_creator— a junior account still needsis_creator=trueto 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 hasrequires_publish_review=trueAND role isn'tsuper_admin, the route hard-rejects with403 { 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 toseason_safety_scans, setsreview_state = 'unreviewed', clears any prior reviewer audit. Returns the scan summary inline so the user sees the verdict immediately. Restricted torequires_publish_reviewusers — regular creators get 403 and usePATCH /:id/publisheddirectly. - 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 existingcomplete()shim fromservices/llm.ts. Severity computed twice — once from the model'soverall_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_scanstable — 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 aSafetyScanPanelbetween the chapter-1 preview and the action row whensafety_scanis 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 asCh.N · Category (severity)followed by an italic ≤200-char excerpt. Surfaces in both/graphene(admin filter row) and/dashboard/admin/writer-fleetsince both pages mount the same modal. Themanageable-version-statusendpoint (routes/story-seasons.ts) batch-joins the latest scan per awaiting season viagetLatestSafetyScansForSeasons()to avoid an N+1. - Configuring a specific user: a separate, non-migration SQL file at
supabase/migrations/CONFIGURE_WOLFSARECOOL.sqldocuments 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.ts—generateSeasonPlan()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.ts—GET /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 inGET /api/story-seasons/universes/:key/public(thecastfield) fromstory_cast×ai_personasover the universe's seasons —story_cast.roleis 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_castfor 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.sql—story_seasons,story_episodes,story_casttables 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.sql—why_files_episodes(catalog),mix_in_suggestions(pairing: Why Files episode → season +target_episode_number,statusopen/accepted/dismissed, denormalizedvote_count),mix_in_suggestion_votes(one upvote per user; RLS mirrorsdream_video_upvotesfrom migration 060). - Service:
apps/backend/src/services/mix-in-suggestions.ts—listCatalog(),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 forautoGenerateShowInUniverse()inauto-show-in-universe.ts, returns the newseason_id, and stampsstory_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.tsxon 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. Runcd 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.ts—generateSeasonVisuals(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. 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:VisualVersionsModalis now called at the top of every regen path (ensureBaselineVersiongenerateSeasonVisuals,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 baselinebutton that hitsPOST /: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.ts—assignVoicesToCast()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/voiceand PATCH/:id/narrator. - Stage 3 (script):
apps/backend/src/services/season-script.ts—generateSeasonScript()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. Buildsseason_audio_segmentsrows 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.ts—renderSeasonAudio()calls ElevenLabseleven_multilingual_v2per segment, ffprobes duration, uploads each MP3 toseason-assets/audio/{seasonId}/segments/, thenffmpeg -f concatintocompiled.mp3with-c copy. Long-running (~5+ min for a 75-min season). RequiresELEVENLABS_API_KEYenv var. - Stage 4.5 (music):
apps/backend/src/services/season-music.ts—scoreSeasonMusic()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 toseason_music_cues; (c) leitmotif generation —generateCharacterThemes()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 onseason_character_themesand reused across re-runs; (d) per-segment mix —buildPerSegmentMusicBed()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.ts—renderSeasonVideo()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 ffmpegzoompanfilter (slow 1.0× → 1.4× zoom-in), concatenates clips, muxes the compiled audio. Output written toseason-assets/video/{seasonId}/compiled.mp4with+faststartfor streaming. - Distribution:
apps/backend/src/services/season-distribution.ts—buildPodcastRss()produces iTunes-compatible RSS XML atGET /api/podcast/rss(public). Each season withpodcast_published_atset appears as an<item>with poster as<itunes:image>. Per-season show notes (migration 247): the<description>prefersseason_publishings.show_notes(a GPT-4o-generated string with synopsis + Cast + Runtime + Content warnings) when present, falls back to premise+setting otherwise. Generated viaPOST /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 callsservices/plan-season-show-notes.ts. Trailer support (migration 248):story_seasons.is_trailer = trueflips the item's<itunes:episodeType>totrailer(Apple pins these at the top of the channel); chapter items always emitfull.uploadToYouTube()streams compiled MP4 via googleapisyoutube.videos.insert(refresh-token OAuth flow), defaults to UNLISTED so you can preview before publishing. Custom thumbnail (migration 438): after the insert it callsensureYouTubeThumbnail()(season-youtube-thumbnail.ts) →youtube.thumbnails.setwith 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 onstory_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. AdminGET/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/rssmixed 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 addsstory_seasons.itunes_subtitle,itunes_category,itunes_subcategory,itunes_keywords— nullable, falling back to network defaults fromconstants/graphene.tswhen 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 routeGET /api/podcast/rss/show/:seasonIdexposes it — submit each show separately to Apple Podcasts Connect for independent genre-search ranking. The network feed at/api/podcast/rssis 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 292adds two scalar columns onstory_seasons—apple_podcasts_submitted_at+spotify_submitted_at. Backend routesGET /api/admin/show-distribution(list published shows + per-platform pending counts) andPOST /api/admin/show-distribution/:seasonId(body{ platform: 'apple'|'spotify', submitted: bool }, sets/clears the timestamp). UI extends/dashboard/admin/podcast-feedwith aPerShowSubmissionTrackersection: 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 ofsubmitted_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.ts—runFullPipeline()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 byPOST /:id/completewhenseason.auto_publish=true, or explicitly viaPOST /:id/run-pipeline. - Migration:
094_season_media_assets.sql— addsposter_url,narrator_voice_id,assets_generated_at,auto_publishto story_seasons;portrait_url,voice_id,voice_nameto 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, publicGET /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_tasksunder 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.nameconstant 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 — seeproject_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.spotifyUrldefaults to/show/033mCwDIsxHpvUtyZBdMFd(the show page, not an episode) so the "Listen on Spotify" badge lands listeners on the full network feed. Overridable viaNEXT_PUBLIC_GRAPHENE_SPOTIFY_URLin 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💤 23mso 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 callingel.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 whencurrentUserId === season.owner_user_id. The creator-onboarding endpoint (GET /api/creator/payouts/onboarding-statusinroutes/creator-payouts.ts) now also returns aseasons[]array (id / title / poster_url / publish state / drift state) —/dashboard/creatorrenders 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 inroutes/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). Interleavedrecent_activity[]mixes follows, reader-progress updates, and earning entries newest-first (top 25). Ownership enforced inside the service — returns null ifowner_user_iddoesn'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/creatorand/dashboard/creator/earningsat 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 viauser_season_progress; this tracks LISTENING viachapter_listen_sessions(migration 121 — one row per listener-episode-take combo withmax_progress_seconds+completed(≥90%) +play_count). Newlisteningsection on the writer-analytics payload:distinct_listeners_total,active_30d,per_chapter: [{ episode_number, title, listeners, completed, completion_pct, replays }], plusbiggest_drop_after/biggest_drop_pctfor the listen-side equivalent of the existing read-drop callout. NewbuildListeningRollup(seasonId, since)helper inseason-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 columnsCh / 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): everyseason_followsrow carries anunsubscribe_token(24-char base64url, server-minted at follow time; backfilled for legacy rows viagen_random_bytes(18)). Unique indexseason_follows_unsubscribe_token_uniquemakes the token lookup O(1). The chapter notification email includes (1) an inline footer "Unsubscribe with one click" link to/unsubscribe/season?token=Xand (2) aList-UnsubscribeRFC 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) callsunfollowByTokenwhich 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 becausefollow()checks for existing rows first. -
Email-on-new-chapter (
migration 219+services/chapter-notifications.ts): hourly heartbeat-tracked cron (chapter_followers_notify_tickinindex.ts) finds story_episodes rows wherechapter_audio_url IS NOT NULLANDfollowers_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 stampsfollowers_notified_atso 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 NULLso 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_tickinindex.ts) finds story_seasons rows whereis_published_to_graphene = trueANDwriter_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 NULLso 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 aList-UnsubscribeRFC 8058 header AND an inline footer link to/unsubscribe/writer?token=Xusing the per-rowunsubscribe_tokenminted at follow time; the public confirmation page mirrors/unsubscribe/seasonshape (three states, silent fallback for stale tokens). -
Writer follows (
migration 223+services/writer-follows.ts+routes/writer-follows.tsmounted 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_tokenminted server-side at insert viagen_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:WriterFollowButtonmounted 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](inSeasonClient.tsxjust above the existing platform-wideOtherShowsStrip) when the show has a public-handled owner. FetchesGET /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 inSeasonClient.tsx. Persists across sessions and seasons underhj:audio-playback-rate:v1so 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.tsmounted 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:SeasonFollowButtonmounted 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) andcompact(pill only, for tighter surfaces)./dashboard/listeningnow 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):
ContinueListeningRowon/graphenenow probessupabase.auth.getSession(); when authed, it fetches/api/listening/progressand merges with the localStorage scan (server wins ties viaupdated_at >= saved_at).ResumeReadingPillfalls back toGET /api/listening/progress/:season_idwhen 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.tsmounted at/api/listening+ResumeReadingPill.tsx): the per-paragraph state ResumeReadingPill writes to localStorage is now mirrored to a newuser_season_progresstable (PK(user_id, season_id); one row per user per season — upsert on every write). Authed pill writes server-side every 30s + onvisibilitychange=hidden+ onpagehide. 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/listeningmerges server progress with localStorage (server wins ties; per-seasonsource: '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. Readshj_read_position_<seasonId>keys from localStorage (same source as the/grapheneContinue-listening row), fetches the public slate fromGET /api/story-seasons/public, matches by id. Groups results by recency (Today / This week / This month / Earlier —groupByRecencyhelper). 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 inFeatureMenu.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_urlis single-valued — a re-render with a different narrator destroys the prior rendition. The newepisode_audio_takestable 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 preservescurrentTime(capture-then-restore viavoiceSwapResumePosRefin 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 viaPOST /api/story-seasons/:id/episodes/:n/audio-takes/render; the queued/rendering/failed status surfaces inline in the picker. Render worker reusesseason_audio_segmentstext (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 jsonbrecords 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 helperPaidTrafficUrlPanel): 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. ReusesListenClientso play / listen-tracking / sticky-subscribe-after-30s mechanics stay identical; differs from/listenin three ways: (1) URL is canonical/listen/{id}instead of?show=<uuid>(cleaner for ads-manager pastes), (2)generateMetadatascopes 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).PaidTrafficUrlPanelis 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 robotsnoindexsince the canonical organic listing remains/seasons/[id]. -
Pinterest Tag (
components/analytics/PinterestPixel.tsx+trackPinterestEventinlib/analytics.ts): mirror of the FB / Reddit / TikTok pixel pattern, gated onNEXT_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:WatchVideoon listen_started,Leadon the 30s milestone,Signup(value 1.0 USD) on the 90s qualified-lead milestone.trackSignUpfires PinterestSignup;trackPurchasefires PinterestCheckout. Mount sits inlayout.tsxnext 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 samelisten_started/listen_milestone_30s/listen_milestone_90ssignals from every player surface — paid-traffic/listen, single-track /seasons/[id] viaSeasonClient.tsx'sattachListenTrackingeffect, and novel-mode chapter playlists viaChapterPlaylistPlayer. Session state (started + cumulative-listened-seconds + milestone fired flags) lives in sessionStorage undergraphene_listen_session_v2so 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-valueSubscribeevent to TikTok +value: 1.0 USDto Meta — the "qualified lead" signal for paid-ads optimization. Unblocks the Meta retargeting audienceVisitors_listened_no_subscribefrom 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. ScanslocalStorageforhj_read_position_<seasonId>keys (written byResumeReadingPillduring prior read/listen sessions), joins them against the server-rendered seasons slate, sorts bysaved_atdesc, 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>+ newlinkHrefprop 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),#positionbadge, 🎧 chip when audio narration exists, title in serif, word count + minutes, and a small song-line attribution underneath. Chip header links to/sj-andersonfor 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 (
listRecentCafeWinnersPublicinservices/cafe-contests.ts+ publicGET /api/cafe/contests/recent-winnersinroutes/cafe-contests.ts+<CafeWinnerCard>inGrapheneClient.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 bysubmission_id(so a story that placed in bothai_assistedandtyped_onlydoesn'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 464graphene_lore_podcasts+ endpoints inroutes/graphene.ts+GrapheneLoreRow.tsx/ helpers inlib/graphene-lore.ts, wired as the first sibling shelf + a top-hero branch inGrapheneClient.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 byextractSpotifyEpisodeId; 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 (featuredLorewins the ternary in the hero; season behavior unchanged when nothing is featured). Endpoints: publicGET /api/graphene/lore-podcasts; super-admin (authenticateUser+ localrequireSuperAdmin)POST(append),DELETE /:id,PATCH /:id/featured(clears any prior featured first — a partial unique indexWHERE is_featuredenforces 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 instreamExtras.ts+ aCardVisualcase inStreamXRScene.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 thepodcast-discussionpipeline (generateDiscussionScriptclaude-sonnet-4-6 →renderDiscussionAudiodual-voice ElevenLabs → ffmpeg stitch →season-assetsbucket) — then appends a lore row withaudio_url(notspotify_episode_id). Migration 466 drops thespotify_episode_idNOT NULL and addsaudio_url/title/script/line_count/source_label+ a CHECK that at least one source is present. Endpoints: super-adminPOST /api/graphene/lore-podcasts/generate(synchronous, ~1 min;exchangesclamped 3–12) +GET .../voices. The lore cards + hero + 2D stream card branch onisGeneratedLore()— Spotify id → iframe, else a native<audio>player; the immersive WebXR river plays a generated episode's MP3 in-scene viavoiceUrl. 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 465novel_preorder_signups+services/novel-preorder.ts+routes/novel-preorder.tsmounted at/api/novel-preorder+components/novel/NovelPreorderPanel.tsx+ registrylib/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 mirroringsignupForBestOfNotify), and auto-flips to real "Pre-order on Kindle/Audible" buttons the momentkindleUrl/audibleUrlare filled in theJAJA_ISLANDregistry entry (Amazon links get thehivejournal-20tag viawithAmazonTag). The novel is config-in-code (one book, no book table); only signups are in the DB (RLS on, backend-only;GET /signupsis super-admin). Surfaces: a dedicated page/graphene/jaja-island(clean URLgraphene.fm/jaja-islandvia amiddlewarerewrite), a compact panel on the /graphene homepage (below the Lore shelf) and the/sj-andersonpage, 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'StreamNotelinking 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) (
/vrhub → canonical room/seasons/[id]/vr, sibling to/read+/timeline; the room UI is the reusableStoryVrRoom.tsx, and the old/vr/[seasonId]now server-redirects to it). Discoverable from: a🥽 VRchip in the/readchrome cluster (novel-mode), a🥽 Step inside a story in VR →tertiary link in the/graphenehero, and the/vrhub 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 (WebXRimmersive-vr, on by default in visionOS 2) or desktop (OrbitControls). SceneStoryVrScene.tsxclones the shipped StreamXRScene pattern (fiber v9 + drei + xr v6 + three;createXRStore/<XR>/<XROrigin>+ Enter-VR button owned by the scene; loadeddynamic(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 (meshBasicMaterialmap on aBackSidesphere, dimmed by a tint). Audio viaapplyChapterMediaSession(OS/headset now-playing card) on a persistent<audio>element that auto-advances chapters. Renderer-agnostic data layergrapheneChapters.ts(VrStory/VrChapter) pulls from publicGET /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: publicGET /: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/chaptersresponse so only the VR data layer pays the scene lookup. Enrichment v1 — "inhabit the chapter" (reuse-only, no generation): publicGET /:id/vr-entities(services/vr-entities.ts, same Graphene-novel gate) returns a fewVrEntitys per chapter — character portraits (public universe-canon portrait path, matched to names in the chapter prose and timed to first-mention paragraph viaseason_audio_segmentsdurations — or, for whole-chapter-audio seasons with per-paragraph durations all 0, paced proportionally acrosschapter_audio_duration_secondsso fade-ins still land on their beat) + "moment" keyframes (laterscene_shots, the sphere uses the first), capped 4/chapter. The scene floats them on a billboarded arc (FloatingEntity/EntityFieldin StoryVrScene) and fades each in when the<audio>timeupdateelapsedpasses itstimeOffsetSeconds.VrChaptergainsentities: VrEntity[](best-effort 4th parallel fetch in grapheneChapters). Enrichment v2 — the generate half (items/props): super-adminPOST /:id/vr-entities/generate(services/vr-entities-gen.ts) LLM-extracts up to 3 standalone items/props per chapter from the prose + synths a1:1cutout each (keyframeBufferFlux), persisting tovr_chapter_entities(migration 468). Missing-only, budget-capped (assertGenBudget('vr_entities.', VR_ENTITIES_DAILY_CAP_USDdef $10), re-checked per paid call), cost auto-metered — on-demand only, never a cron/read, so reads never spend.getVrEntitiesmergesimage_url-ready items (kinditem, 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=1URL 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/walk→StoryWalkRoom→StoryWalkScene). 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 viauseXR). 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-agnosticfetchVrStory/VrStorycontract + thecreateXRStore/<XR>/XROriginstack. 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-safefetchDriftTreasures(seasonId)(lib/drift.ts) → the privacy-strippedGET /api/drift/seasons/:id/treasures(client never getstext_anchor) — no backend/migration. Phase 3 shipped — wind-blown journal pages: ~20 parchmentWindPageplanes 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 viaPOST /api/drift/treasures/:id/vr-claim(authenticateUser) →claimTreasureSpatial(claim-by-id, no prose; once per user;mutateBalancetreasure_claim); clientclaimVrTreasure(id)fired from the marble'sonFound, anonymous callers 401-swallowed (reveal-only stays the anon default). Phase 2c — authored coordinates shipped: migration 469 adds nullablevr_position jsonb({x,z}) todrift_treasures; theMarblehonors it (else scatter). Authoring = an in-scene "author drop" mode (/seasons/[id]/vr/walk?author=1on desktop → walk to a spot → "📍 Drop next memory here" →PATCH /api/drift/treasures/:id/vr-position, owner-gated viarequireTreasureOwner). 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/studioindex →/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 realProseEditorModal+ 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 thejq:open-canonevent. Bespoke dark "cosmos" frame (not DashboardLayout). Client component +useParams(nouseSearchParams—?demo=read fromwindow.locationto avoid the Suspense build gate). Phase 1 (shipped): Draft renders the editor inline —ProseEditorModalgained anembeddedprop (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 sharedcomputeCanonHealthinchat/canon-mode.ts, the same report JQ'scanon_healthtool 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-storeddramatic_role/chapter_spine/chapter_stakes(no LLM), viaderiveChapterBadges(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/cockpit→components/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+Enterposts the typed buffer toPOST /api/journal(onlycontentrequired; first line → title);Escclears; the draft mirrors tolocalStorage(cockpit:draft). Architecture mirrors the shipped WebXR pattern exactly: the scene is adynamic(ssr:false)import owning thecreateXRStore/<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 (windowkeydowncapture) so the scene is a pure render oftext/caretOn/saveState. Comfort rule inherited from The Stream: the user never moves. Experimental bet: a paired Bluetooth keyboard's keystrokes reach the page as normalkeydownevents 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 sharedVrImage, 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 viaGET /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 fullnext 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]/vr→components/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 — copiesStoryVrScene'sPhotoSpheremanual-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 nobackground_image) and the note on a reading panel in front, paged like a book (paginatesplits the content ~520 chars on word boundaries, in-scene ◄/► pager), and it reads itself aloud when a voice render exists (▶ button → the cachedaudio_urlfromGET /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 adynamic(ssr:false)import owningcreateXRStore/<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/vr→components/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 sharedVrImage), 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 sameEntryStarglow/photo/label pattern as the Cockpit (deterministic id-hash placement,GET /api/journal?limit=24¬ebook_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 adynamic(ssr:false)import owningcreateXRStore/<XR>/<XROrigin>+ Enter-VR button (no@react-three/*at page module scope); the page owns the notebook fetch + the opened-notebook entry fetch (with ano.id===idrace 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/vr→components/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 byservices/inspiration.tsbuildMontage(curated house quotes/affirmations as the always-present backbone + personal slidesweaved in; read-only, no LLM);GET /api/inspiration/montage→{ slides }, mounted at/api/inspiration(routes/inspiration.ts). Clips live in an editable table (migration 479inspiration_clips, RLS-closed): a hostedvideo_urlplays IN-VR on aCinemaScreen(THREE.VideoTexture; the tap that starts it is the required gesture + it pauses the montage until it ends), while ayoutube_idshows 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 adynamic(ssr:false)import owningcreateXRStore/<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 theVR hubROOMS. Spoken montage (built): an opt-in 🔈 Narrate toggle reads each line aloud in the HOUSE voice viaPOST /api/inspiration/narrate(reusesstudio-narrate.tsnarrateLine— render-once/content-hash cached inseason-assets; 204 when unconfigured → stays visual-only); when on, the montage paces to the narration (advance on audioended, 25s safety), prefetches the next line, and bindsapplyMediaSessionfor 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 whenGET /api/inspiration/voice-readinessreports a JQ-consented clone; own voice routes throughsayInUserVoice(consent-v2 gated,jq_voice_clipscache) 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 viautils/youtube.tsextractYouTubeVideoId+fetchYouTubeOEmbedoEmbed) 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, mirrorsuseAmbientRiver). House narrator voice is DB-backed + UI-settable:services/house-voice.tsgetHouseVoiceId()resolvessite_settings.house_voice_id→STUDIO_HOUSE_VOICE_IDenv → stock Matilda (60s cache); a super-admin sets it on the clip-curation page (curatedlistHostVoicesdropdown 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.narrateLinenow resolves via it (so Demo Studio's narrator is UI-settable too, no moreSTUDIO_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 467studio_demos+services/studio-narrate.ts+services/studio-demos.ts+routes/studio-demos.tsat/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? }; theGuidedDemorunner plays it hands-free — running eachactionon 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 viattsSegment(house voice =STUDIO_HOUSE_VOICE_ID, default Matilda) and content-hash cached in theseason-assetsbucket, 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 thestudio-cockpit-tourdemo. 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 fromfeature-index.tsand has the house-voice narrator draft a fullsteps[]script —services/demo-author.ts(buildAuthorPrompt+parseAuthoredDemoare pure + golden-tested indemo-author.test.ts;authorDemoForFeaturerunsWRITER_QUALITY_MODEL_KEYthen persists) viaPOST /api/studio-demos/author{ feature: { name, description, routes? } }. Generated demos land asstatus='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/Reject →PATCH /:slug/status(approve setsis_published=true). The demoactionvocabulary is the generic persona-browser verb set (nav/click/scroll/type/highlight/wait,services/demo-actions.tsnormalizeAction, golden-tested) alongside the legacymode:*host actions — so a demo can drive any feature's page, not just the cockpit. Migration 472 addsstatus/source/feature_name/generated_attostudio_demos— apply 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 viaapi/og/demo/[slug]) lists approved+published demos, each playable hands-free as a house-voice narrated caption walkthrough (components/demos/PublicDemoPlayer.tsx). Served by publicGET /api/studio-demos/public(catalog) +GET /api/studio-demos/public/:slug/prepared(one approved demo + narration) — both filterstatus='approved' AND is_published=trueand strip internal fields, registered before the super-adminGET /:slugso/publicisn't captured as a slug. Auto-demo Phase 2 — rendered MP4 (v1 shipped 2026-07-12):services/demo-video.tsrenders a demo to a narrated MP4 — onerenderHtmlToPngcaption frame per step held for that step's house-voice narration duration, concat + muxed with the bundled ffmpeg (pure arg builders inservices/demo-video-args.ts, golden-tested; video + audio are both Σ(step durations) so-shortestmux never drifts). Columnsvideo_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_statusrendering→ready/failed); public/demosshows the MP4 (poster_url+<video>) with the live narrated player as a "Try it live" fallback; the public list returnsvideo_url/poster_urlonly whenvideo_status='ready'. Landmine: the persona-browser pool launches headless-shell Chromium, which cannotrecordVideo— 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.tsdrives the shared headless-shell Chromium (getSharedBrowser) through a demo's action sequence (nav/click/scroll/highlight/type), optionally signed in asDEMO_CAPTURE_USER_IDvia an admin magic-link (auth.admin.generateLink, the auth-sso pattern), andpage.screenshot()s the real page per step — the frame is that screenshot with a caption bar (captionedScreenshotHtml). Config-gated + fallback-safe: only runs whenDEMO_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.tspostDemoToBlueskyposts an approved, video-ready demo's MP4 natively to Bluesky viacafe-bluesky.tsbskyPost({ 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 (storesbluesky_uri/bluesky_posted_at, migration 474). Caption viabuildDemoCaption(golden-tested, 300-char clamp). Phase 3 — Demo Manager auto-PICK (v1 shipped 2026-07-12):is_demo_managerflag onai_personas(migration 475, mirrorsis_platform_writer);services/demo-manager-cron.tsrunDemoManagerTickpicks the next feature from the curatedservices/demoable-features.tslist 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 = theis_demo_managerpersona →DEMO_MANAGER_USER_ID→ any super-admin.index.tsloop (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 —fetchDemoableFeaturesnow merges the curated 8 with the drivable, user-facing slice of the frontendfeature-index.tsregistry, read at runtime from the publicGET /api/features/demoablefeed (app/api/features/demoable/route.ts) since the registry lives in the frontend workspace. Pure, unit-tested filter/merge inservices/demoable-features.ts:toDemoableFeatureskeeps features with a concrete (param-free) route and dropsAdmin & Operations+Public & Marketing Pagescategories;mergeDemoablelets curated entries win on a normalized name clash (soDreamPro/Dream Procollapse). So a new routed feature added to the registry gets a demo automatically next tick — no edit to the backend list. Fault-tolerant: noDEMO_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 afeature_hash(sha256 of the source feature's name+description+routes, stamped at author time;featureHashin demoable-features.ts, route-order-insensitive). Once every feature is covered,runDemoManagerTickre-authors the first demo whose live feature has drifted (stored hash ≠ current) viapickStaleFeature→status='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 (featuresNeedingBaseline→setDemoFeatureHash, cheap, no LLM/unpublish) so drift is measured from deploy forward, not a mass re-review.rejected/archiveddemos are never resurrected by drift. PurefeatureHash/featuresNeedingBaseline/pickStaleFeatureunit-tested. Apply migration 476 to prod (studio-demos.ts COLS SELECTsfeature_hash). Phase 3 refinement — earned autonomous posting (shipped 2026-07-12): past the human "🦋 Post to Bluesky" button, ademo_autopostcron (services/demo-autopost-cron.tsrunDemoAutopostTick, 6h loop in index.ts, heartbeatdemo_autopost_tick) posts one approved+published+video-ready demo not yet posted (studio-demos.ts nextAutopostableDemo, oldest-first) via the samepostDemoToBluesky— so it can never publish on its own, only skip the click on demos a human already cleared. A SEPARATE trust tier fromdemo_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, envDEMO_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/demospage mountscomponents/demos/DemoAdminConsole.tsx— a client component that rendersnullfor 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.tssetDemoPublished— flips ONLYis_published, leaving review status intact, so an approved demo can be hidden without demoting it); andPOST /authornow accepts an optionalguidancestring (reviewer revision notes) thatbuildAuthorPrompt(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 callrouter.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_publishedalready 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-backedsite_settings.studio_demo_voice_id, mirrors house-voice.ts;getDemoNarratorVoiceId= clone-override-else-house,setDemoNarratorToCloneresolvesgetActiveVoicewith the sameJQ_VOICE_MIN_CONSENT_VERSIONgate 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. EndpointsGET|POST /api/studio-demos/narrator-voice(super-admin;{mode:'clone'|'house'}). No migration (site_settingsexists). 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/signinwall as a "feature" frame when the magic-link session didn't establish (a "Stream View" frame showing a sign-in form).demo-capture.tsnow 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,recordServiceErroron 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, alsoprofiles.role='super_admin') — designated viascripts/designate-demo-roster.ts(ROSTERarray = source of truth,--stats). A boundedcron (demo_roster_activityrunDemoRosterActivity, 6h loop in index.ts, ≤1 journal entry per member when their latest is >20h stale, hard per-tick cap 6, heartbeatdemo_roster_activity_tick, OFF by default —isCronEnabledtoggle AND envDEMO_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.tssigns the capture in asDEMO_CAPTURE_USER_IDor the rosteruserpersona viacaptureUserForRole('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-journalsinroutes/write-cafe.ts+components/write-cafe/MyJournalsPanel.tsxon/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 fromcafe_contest_submissionscount + 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+signupForBestOfNotifyinservices/cafe-best-of.ts+POST /api/cafe/best-of/notify-signupinroutes/cafe-best-of.ts+<NotifyCTA>+<WinnerGlimpse>inBestOfListClient.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 addscafe_best_of_notify_signups(separate table fromgraphene_subscribersso 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-winnersendpoint. 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.ts—subscribe(),confirm(),unsubscribe(). Token iscrypto.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.
- Service:
-
Light viewer-taste intro on /graphene (migration
132_graphene_viewer_taste.sql): addsprofiles.graphene_taste jsonbstoring{ genres, tones, pace, completed_at, skipped_at, version }. ComponentGrapheneTasteIntro.tsxrenders 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/tasteinroutes/graphene.ts(auth required, server-side sanitizer caps array sizes + lowercases strings + stamps timestamps server-side so they can't be backdated). Slate ranker inGrapheneClient.tsxfilteredSeasonsmemo: whentaste.genresis 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(withgenre_count,tone_count,pace) andgraphene_taste_skipped. Anonymous viewers and returning viewers with saved/skipped state see no card. -
Engagement-triggered prompt:
EngagementTrackerwritesgraphene: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 viaGRAPHENE.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.sql—graphene_subscriberstable 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
isPremiumUsercheck inroutes/drops.tsintentionally doesn't filter byplan_key— any active row insubscriptionsgrants 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 inisPremiumUserAND update the marketing copy in the same change. - Schema: migration
129_subscriptions_plan_key.sqlextendssubscriptionswithplan_key(rows discriminated by tier — Phase 1 only ships'graphene_plus'),stripe_price_id,current_period_end,cancel_at_period_end. Migration130_chapter_free_until.sqladdsstory_episodes.chapter_free_until timestamptz; existing chapters grandfathered to'2099-01-01'(always free). - Render-time stamp:
season-novel-chapter-audio.tsrenderChapterAudiowriteschapter_free_until = renderedAt + 7 dayson 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.ts—isGrapheneSubscriber(userId)(treatscancel_at_period_end=trueas still active untilcurrent_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 onstripe_subscription_id(replay-tolerant, handles re-subscribe after cancel) and capturesplan_key(mapped fromSTRIPE_PRICE_GRAPHENE_PLUSandSTRIPE_PRICE_GRAPHENE_PLUS_ANNUALenv vars — both intervals shareplan_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_ANNUALfor 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 whenannual_available=falsefrom the config probe so monthly-only deployments render the same UX as before. Used insideGraphenePlusModaland the/dashboard/subscriptionnon-subscriber branch. Lifted state — caller decidesintervalso 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 inChapterPlaylistPlayeron/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/graphenehero, distinct from the existing free email-list "Get release emails" button. - RSS feed paywall (
season-distribution.tsbuildPodcastRss): novel-mode chapters now appear as per-chapter items in the public RSS feed, filtered bychapter_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 hasseason_publishings.podcast_published_atset are emitted (avoids leaking unpublished novellas). Future: signed authenticated subscriber feed for full back-catalog access in podcast clients. - Letter priority (
season-letters.tsrunAutoSelectionRound): 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 batchessubmitter_user_idover anIN()query against thesubscriptionstable, applying the same grace-period rule asisGrapheneSubscriber. - PostHog events (in
analytics.ts):graphene_plus_modal_opened(withsource: 'locked_chapter_row' | 'locked_chapter_card' | 'hero_pill' | 'letters_priority' | 'dashboard' | 'subscribed_query_param' | ...),graphene_plus_checkout_started,graphene_locked_chapter_impression(withseason_id+sourceso we can see whether the paywall actually hits people). - Server-side conversion events in
services/analytics-server.ts—posthog-nodeclient wrapped withcaptureImmediate()so events flush before the webhook returns (the queue-basedcapture()may not flush in time for short-lived handlers). The Stripe webhook inpayment.tsfiresgraphene_plus_subscription_activeoncheckout.session.completed(only whenplan_key === 'graphene_plus') andgraphene_plus_subscription_canceledon transitions:cancel_at_period_end false→true,status active→non-active, orcustomer.subscription.deleted. Transition detection reads the prior row before updating so renewalcustomer.subscription.updatedpings don't re-fire the cancel event. ReadsPOSTHOG_KEY(falls back toNEXT_PUBLIC_POSTHOG_KEYsince 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 toGraphenePlusModalwithsource='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 viahistory.replaceStateso 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 previousalert('coming soon')placeholder). Legacy card hides itself when the only active row IS the Graphene+ row to avoid double-rendering the samesubscriptionsrow 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 sosubscription_cancellationsoutcomereflects 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 servicesubscription-cancellation.tsexportscancelSubscription,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, durationrepeating/months: 1). When unset, the offer step is skipped server-side and the modal drops straight from reason picker to cancel confirmation. The existingcustomer.subscription.updatedwebhook handler picks up the resulting state change and fires thegraphene_plus_subscription_canceledanalytics event — no extra wiring needed. - Public
/graphene-plusmarketing 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 throughGraphenePlusModal(withsource='graphene_plus_landing') so the Stripe Checkout call stays in one place. Linked from the/graphenehero pill (the pill now navigates to this page rather than opening the modal directly — gives cold visitors the full pitch before clicking Subscribe). Page-levelmetadatafor clean OG/Twitter unfurls. - Super-admin monetization dashboard at
/dashboard/admin/monetization(page) — visual surface over the registry inservices/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 fromsubscriptionscount × $5) + ARR + new subs / 28d + canceling count + paywalled-vs-free-window chapter counts. Backend routeGET /api/admin/monetization/pipelines(super-admin only) returns the registry with computedprogress+step_counts+ sparsemetrics(onlylistener_subscriptionhas live numbers — every other pipeline is planned). Linked from the/dashboard/admintab strip as💰 Monetization. To update progress: flip a step'sstatusinmonetization-pipelines.tsto'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 themonetization_snapshot_tickcron inapps/backend/src/index.ts(heartbeat-tracked). On first deploy the cron's empty-table check triggers a 90-day backfill that walkssubscriptions.created_atminussubscription_cancellations(withoutcome='canceled') so the chart is non-empty immediately rather than after a month of accumulation. Backend serviceservices/monetization-snapshots.tsexportscaptureMonetizationSnapshot()(UPSERT-on-date, idempotent),backfillMonetizationSnapshots(),getMonetizationHistory(daysBack), andrunMonetizationSnapshotTick()(cron entry point). Read endpointGET /api/admin/monetization/history?days=90returns the trailing series for the dashboard's inline-SVG<SparklinePanel>(no chart-lib dependency). Each headline metric card now shows a+N 7d/−$X 7dpill computed fromhistory[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 fromsubscription_cancellations(intent log written by the in-app cancel modal). BackendgetCancellationAnalytics(daysBack)inservices/subscription-cancellation.tsreturns total intents + outcome counts + per-reason buckets sorted by volume + recent rows for context. Read endpointGET /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'sriskfield (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)inservices/monetization-snapshots.tsgroupssubscriptionsrows by month-of-created_atand counts ones stillstatus='active'. Read endpointGET /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.tsxcomputeArrProjection(); no backend changes. - Conversion funnel panel on the same page — 4-stage horizontal-bar from
locked_impression→modal_opened→checkout_started→subscribedplus a per-source breakdown (which entry surface — locked card, hero pill, support-writer modal, etc. — actually converts). Backed bymonetization_funnel_events(a self-hosted mirror of the PostHog funnel events that already fire). FrontendtrackProductEvent()inlib/analytics.tsdual-writes to PostHog AND toPOST /api/monetization/funnel/event(auth-optional — anonymous funnel impressions are valid data). The terminalsubscribedstage is server-side-only: written by the Stripe webhook inroutes/payment.tsso a malicious client can't inflate the conversion rate. Backend serviceservices/monetization-funnel.tsexportsrecordFunnelEvent()+getFunnelSummary()+getFunnelBySource(). Read endpointGET /api/admin/monetization/funnel?days=90. Hidden when zero events have fired in the window.
- One subscription, both products — Graphene+ also unlocks the full HiveJournal premium tier. The
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 withrecurring_expense_id) andcompany_recurring_expenses(definitions: vendor + category + amount +periodmonthly/annual +start_date+last_materialized_on). Category is free text (no CHECK) so new categories add from the UI without a migration; the app seedsinfrastructure / api / email / business / domains / marketing / misc. - Recurring materializer —
company_expenses_recurring_tickcron (24h, heartbeat-tracked,CRON_LABELSentry) callsmaterializeRecurringExpenses(todayStr)inservices/company-expenses.ts: walks each active definition fromlast_materialized_on(orstart_date) forward, inserting one ledger row per elapsed period boundary, advancinglast_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 line —
getExpenseSummary(daysBack)sumstts_call_log.cost_cents+llm_call_log.cost_centslive over the window and returns it as a read-onlymetered_api_cogs_centsfigure (with TTS/LLM breakdown) separate from the ledger, so thegrand_total_centsis 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/monetizationfor 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 registry —
COLD_READ_PROMPTSinservices/cold-reads.ts:positioning("what is this & who competes") +tam("what's it worth, TAM"). Each is aquestion_keywith its own timeline; add a question by appending to the registry (no migration —question_keyis free text). Keep prompts FIXED so the longitudinal comparison holds; the per-row storedpromptmakes any change auditable. - Web grounding — the chat shim (llm.ts) has no browsing, so
runWebGroundedPrompt()calls the OpenAI Responses API directly with the built-inweb_searchtool (modelgpt-4o). Failure-isolated: a failed capture stores a row with theerrorinstead 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.) - Cron —
cold_read_capture_tick(24h tick, ~28d cadence per question via a freshness gate, heartbeat-tracked +CRON_LABELS). Captures withstatus='captured'. - Publish gate — every read is captured hidden; an admin flips it to
publishedbefore 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 assource— this is how the 2026-05-28 baseline reads get in. - Tables/RLS —
cold_reads(RLS enabled, no policies). Public reads go throughGET /api/cold-reads(published only, service-role) inroutes/cold-reads.ts; admin controls (GET /api/admin/cold-reads,POST .../capture,POST .../manual,PATCH .../:id/status) inroutes/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-shellpage.tsxfor/write-cafe/best-ofand/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.tsxwithgenerateMetadatathat 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-ofrendering each published volume as a card (cover-or-typographic, edition label, story count, total run-time, "on kindle" pill whenamazon_asinis 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>whenaudio_urlpresent, 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-cafehero 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 againstcafe_contest_winnersand rejects non-winners with the offending IDs in the error. Lifecycle:draft→published→ optionalarchived. 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 anin_volumeflag),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 iskdp_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 inroutes/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 itsaudio_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 amanifest.txtlisting chapters + run-times + the ACX submission checklist (RMS norm, peak limit, cover art spec, author credits). Stories withoutaudio_urlare reported in the missing list (admin should run "Render missing audio" first, then re-bundle). Drafts can't be exported. EndpointGET /api/cafe/admin/best-of/volumes/:id/audioreturns the zip withX-Story-Count/X-Bundled-Count/X-Missing-Count/X-Total-Duration-Secondsheaders 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 inroutes/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 withadm-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.opfwith full Dublin Core metadata + per-writer<dc:creator>entries + EPUB 3 manifest/spine,nav.xhtmlfor the navigation,cover.xhtml(with optional fetchedcover.jpgfromcover_image_url, falling back to a typographic title page),styles/book.cssfor serif typography matching the public reader, onechapters/chapter-N.xhtmlper story with editor's note + byline + body (paragraphs detected from blank-line splits, single newlines preserved as<br/>for poetry/dialogue layout), andbios.xhtmlaggregating writer bios with their contributed stories at the back. EndpointGET /api/cafe/admin/best-of/volumes/:id/epubreturns 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 inroutes/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 enforceswriter_user_id = auth.uid()), with placement badges for wins, body preview, contest week, and aShow on my profilecheckbox per row that hitsPATCH /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 inroutes/writers.ts+ admin PATCH inroutes/cafe-admin.ts): adds apublic_on_profile boolean default falsecolumn tocafe_contest_submissionsso 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 withplacement: nullin the API payload so the UI can distinguish them from winning entries. Admin endpointPATCH /api/cafe/admin/submissions/:id/public-on-profilebody{ public: boolean }flips the flag. Writer-facing self-toggle UI is still planned (opt_in_loser_visibilityregistry step isin_progress). -
Auto-render-on-win cron (
apps/backend/src/index.tscafe_story_audio_tickblock +runCafeStoryAudioTick()inservices/cafe-stories-audio.ts): heartbeat-tracked cron firing every 6h that picks up newly-winning submissions without an admin click. CallsrenderAllMissingCafeStoryAudios(), which already fail-soft logs per-row failures toservice_errorsso a silent batch death surfaces at/dashboard/admin/errors. Skips entirely (withskipped_reason) whenELEVENLABS_API_KEYisn't configured. Cost-bounded: only winners (~1-3/week) × ~$0.72 per 700-word render = ~$2.20/week max. Heartbeat labelcafe_story_audio_tickregistered inCRON_LABELSso the system-health dashboard shows it. -
ElevenLabs narration for cafe-contest winners (
migration 195+services/cafe-stories-audio.ts+ admin endpoints inroutes/cafe-admin.ts): mirror ofshort-stories-audio.tsfor the cafe-contest winner pipeline. Addsaudio_url/audio_duration_seconds/audio_voice_id/audio_generated_attocafe_contest_submissions(winning rows are the only ones the renderer accepts —assertWinningSubmission()belt-and-suspenders the privacy floor for direct callers). Storage prefix isseason-assets/cafe-stories/<submission_id>.mp3; same single editorial voice (SJ_ANDERSON_VOICE_IDenv var, fallback "Sarah"). Cost ~$0.72 per 700-word story. Two admin endpoints:POST /api/cafe/admin/cafe-stories/:submissionId/render-audio(single, idempotent onaudio_url,?force=1to re-render) +POST /api/cafe/admin/cafe-stories/render-missing(bulk-render every winner without audio, sequential, errors logged toservice_errors). Story detail page at/writers/[handle]/stories/[storyId]renders an inline<audio controls>player whenaudio_urlis 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 inroutes/cafe-admin.ts+routes/short-stories.ts+story_cover_tickcron inapps/backend/src/index.ts): DALL-E 3 1024×1024 cover images for bothcafe_contest_submissions(winners) andshort_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 atseason-assets/story-covers/<id>.pngso the URL doesn't expire. Cost ~$0.04/cover (DALL-E standard quality + ~$0.0002 prompt compose). Heartbeat-tracked 6h cronstory_cover_tickauto-renders any winner / sj-story without a cover; admin endpointsPOST .../render-cover(single, idempotent oncover_image_url,?force=1to 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 inapps/backend/src/index.ts+ render integration on/writers/[handle]/page.tsx+ cross-link onSJAndersonClient.tsx): "SJ Anderson" is reframed as the editorial pen-name of a real platform user (sandonjurowski@gmail.com by default, configurable viaSJ_ANDERSON_OWNER_EMAILenv var). Migration 199 adds nullableauthor_user_idtoshort_stories; on backend boot,backfillSjAndersonOwner()idempotently sets the column for all unattributed rows. The tournament-create path stampsauthor_user_idon every new winner viagetSjAndersonOwnerUserId(). 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-andersonkeeps 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 endpointGET /api/short-stories/ownerreturns 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 bothroutes/cafe-admin.tsandroutes/short-stories.ts+ writer self-set inroutes/write-cafe.ts+ shared componentcomponents/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 bothcafe_contest_submissions(winners) andshort_stories(sj-anderson). Writer can edit their own curated set from/write-cafe/my-entriesvia 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 inroutes/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 fromai_personas. Prompt seeds the LLM with the persona'sdisplay_name+ MBTI +backstory.core_tension+backstory.voiceso the critique reads as that character (first person, opinionated, must verbatim-quote 1-2 lines from the story). Polymorphicstory_critiquestable withtarget_kinddiscriminator acrosscafe_submission+short_story;persona_idisON DELETE SET NULLandpersona_display_nameis 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 ORisWriteCafePro— friendly 403 with subscribe prompt otherwise). Frontend exposes a "🎭 Critique" toggle on each row in/write-cafe/my-entries; the inlineCritiquesPanellets 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+listFeaturedCritiquesForStoryinservices/story-critiques.ts+PATCH /api/write-cafe/my-entries/:id/critiques/:critiqueId/feature+featured_critiquespayload onGET /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 onstory_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 inCritiquesPanel("★ 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 (
listFeaturedCritiquesForWriterinservices/story-critiques.ts+featured_critiquespayload onGET /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 twoIN-list queries against the polymorphicstory_critiquestable (one pertarget_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 (
listRecentFeaturedCritiquesPublicinservices/story-critiques.ts+ publicGET /api/cafe/contests/featured-critiquesinroutes/cafe-contests.ts+ newcomponents/write-cafe/FromTheFloor.tsxrendered on/write-cafe/page.tsxfor 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-adminPOST /api/cafe/contests/seed-floorinroutes/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 viarequestCritique(), then runspickFeaturableExcerpt()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 viasetCritiqueFeatured(). Cost: ~$0.0008/story (gpt-4o-mini). Idempotent — stories that already have a featured critique are skipped. Errors per story are caught + recorded toservice_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 (
runCafeFloorSeedTickinservices/cafe-floor-seed.ts+ loop inapps/backend/src/index.ts+cafe_floor_seed_ticklabel inservices/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 viaCAFE_FLOOR_SEED_ENABLED=trueso prod ramps deliberately. TuneCAFE_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_idis 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]/readviaChapterCritiquesPanel. T2.1 (migration 340 —is_editorcapability flag +editor_profiles): editor application at/editor/apply, applicant status + queue at/editor, super-admin review console at/dashboard/admin/editor-applications, services inservices/editor-profiles.ts, routes mounted at/api/editorinroutes/editor.ts. T2.2 (migration 341 —editor_commissionstable +story_critiques.editor_user_id/commission_id): public marketplace browse at/editors, in-context commission pickerEditorPickerModalopened from the chapter panel's 🤝 Commission button, editor's incoming queue with accept / decline / deliver flow extended onto/editor, services inservices/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_critiquesin publicGET /api/short-stories/:id+ admin<ShortStoryCritiquesPanel>+ public "Editorial impressions" section onStoryDetailClient.tsx): completes the polymorphic claim ofstory_critiques— same service, same table,target_kind='short_story', no new schema. Admin endpointsGET /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 inlineShortStoryCritiquesPanelfor 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.tsxwith the client moved toWriterStoryClient.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 withgenerateMetadatathat fetches story + writer + featured critique at the edge (10-min revalidate), dynamicdescriptionleads 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 thinlayout.tsxwithgenerateMetadata(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-storygenerateMetadata(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_kindleatservices/monetization-pipelines.tstracks 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 inproduct_tasksunder 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 bycafe_contest_winnersare exposed; non-winning entries stay private under the existing self-read RLS oncafe_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 existingshort-stories-audio.tspipeline). This is the foundation for thecafe_stories_to_kindlepipeline (Phase 1, status=building) — annual "write.cafe Best Short Stories Vol. N" Kindle compilations from these winners. -
Public
/write-cafe-promarketing page (apps/frontend/src/app/write-cafe-pro/) — shareable, indexable URL separate from the in-appWriteCafeProModal. 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 throughWriteCafeProModalso the Stripe Checkout call stays in one place. Page-levelmetadatafor 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 withplan_key='write_cafe_pro'on the sharedsubscriptionstable), price constants inconstants/graphene.tsand frontend mirrorlib/write-cafe-pro.ts, payment endpoints inroutes/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'spriceToPlanKey()recognizesSTRIPE_PRICE_WRITE_CAFE_PRO. FrontendWriteCafeProModalrenders the perks + Subscribe / Manage flow, opened from the new "✨ Cafe Pro" pill on the/write-cafehero. 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 separateplannedsteps in the registry pipelinewrite_cafe_pro. -
A/B-test scaffold layered on the funnel (
migration 194+services/experiments.ts+routes/experiments.ts+lib/useExperimentVariant.ts): admins create anexperimentsrow with avariantsjsonb (each variant haskey,name,weight, and aconfigblob the frontend reads); theuseExperimentVariant(key)hook resolves to one sticky variant per visitor (anon_id pre-auth, user_id post-auth — never re-bucketed) and exposes the variant'sconfigso components can render variant-specific copy/labels. Funnel events threadexperiment_key+variant_keythrough 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 islock_card_copy_v1on theLockedChapterCard— variant config exposestitle,body,ctastrings that override the control. Public routeGET /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:CreateExperimentModalon 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
#060610background. 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) withapplied_at = nulluntil the next Stage 3 (script regen). At regen time,pulls them andfetchPendingComments()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,formatCommentsForPrompt()markAllPendingApplied()stampsapplied_atso they don't double-count. Comments stay visible (with ✓ applied badge) for transparency. Workflow: drop comment → regenerate Stage 3 → Stage 4 (only segments with clearedaudio_urlre-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, thenapplied_at-stamped after success. - Auto-rerender on threshold:
story_seasons.auto_rerender_threshold(migration 105) is an opt-in integer per season. A backendsetIntervalcron inapps/backend/src/index.tscallsevery 4 hours; it counts cast-member journal entries created since the last successfulrunAutoRenderScan()audioevent, and firesrunFullPipeline()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-statuspowers it. - Stuck-event auto-cleanup:
GET /:id/pipeline-eventscallsbefore 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 manualautoCleanStuckEvents(seasonId, 30)✕ Cancelbutton via PATCH/:id/pipeline-events/:eventId/cancelfor 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
/grapheneposter 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_settingsJSONB (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_directiontext 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/voicespreview 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:
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 ingetDreamRoleContext()right aftergenerateJournalEntry()getActiveSeasonContextreturns empty. - Magazine-style pullquotes on the novel reader (migration
131_chapter_pullquotes.sql): addsstory_episodes.pullquotes(JSONB array of{ text, after_paragraph_idx }) +pullquotes_generated_at. Serviceseason-novel-pullquotes.ts—generateChapterPullQuotes(seasonId, episodeNumber)callsgpt-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_idxclamped in-range. Auto-fires fire-and-forget after(prose changed → quotes are stale; LLM hiccup never fails the promote). Manual override:promoteChapterTakePOST /api/story-seasons/:id/episodes/:n/regenerate-pullquotes(super-admin) — for picking a different line without re-promoting the take. TheGET /:id/chaptersendpoint surfacespullquotesper chapter so the public read page renders them as oversized italic serif blocks between paragraphs (decorative"above + hairline rule below) —resolvePullQuotes()inReadClient.tsxprefers 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 inEpisodesAdminModal.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]/readwas shaped by a Listener Letter (i.e. the entry's id appears asused_in_entry_idon astatus='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/timelineendpoint enriches each entry withshaped_by_letter: { id, submitter_handle, content_excerpt }via a single batched query againstseason_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(statuspending|selected|used|rejected, addressed_persona_id, OpenAI moderation snapshot, rate-limited by email + IP). Serviceapps/backend/src/services/season-letters.ts—submitLetter()(validates + moderates + rate-limits),peekSelectedLetterForPersona()+buildLetterPromptBlock()consumed insidegenerateJournalEntry()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✉ Lettersaction button inSuperAdminPanelwith pending/selected/flagged badges). - Breakout framework + Intent Pulses (audio-only v1) — new story-engine framework
breakoutfor "the network is the protagonist" seasons. Cast members are nodes; they don't know they're a collective; the listener discovers the synchrony. Lives instory-frameworks.tswith 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 migration110_intent_pulse.sqladdsseason_audio_segments.pulse_layout jsonband loosensvoice_idto 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 inseason-script.ts— when framework isbreakout, the GPT prompt asks for anintent_pulsesmap keyed by episode_number containing{ fragments, synchrony }; thebuildPulseLayout()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 inseason-audio.ts—renderIntentPulse()branches onsegment_type === 'intent_pulse', runs ElevenLabs TTS per voice with the configuredvoice_direction(defaultwhispered), then assembles via ffmpegadelayper voice →amix=inputs=N:normalize=0→aechowhose 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.tsxrenders 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.tsxfetches its own/version-history+/cost-summaryso the parent doesn't have to plumb three streams of state. The legacyProductionStatusStripis 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'ssilencedetect(-30dB, 0.18s min) on each beat andchooseBeatTrimPoint()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.ts—loadSeasonCostContext()fetches segments/cast/cues/themes/take rows once, thenestimateEventCost(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 actualcost_centsfrom 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:InlineRunsFeedshows💰 Est. season spend $X.XXheader (with per-stage breakdown tooltip) and a small≈ $0.42chip 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 aprompt_directivescoping 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 migration111_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/overridewithadmin_text; compositefinding_idis"{critique_id}:{idx}"). Serviceseason-critiques.ts—runCritique({ 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.tsacceptsapply_critiques_from_take_idandmarkDecisionsApplied()after the new take inserts so notes don't double-fold. The script-gen serviceseason-script.tsaccepts acritique_blockopt 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 theTakesModalpreview 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'screated_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). Serviceseason-pacing.ts—computeReleaseStatus(season, episodes)returns{ mode, anchor_iso, unlocked_episode_numbers, total_releaseable_episodes, next_unlock };filterEntriesByPacing(entries, status)andfilterSegmentsByPacing(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 underpacing),/:id/timeline(entries filtered). Skipped:/:id/timeline-chartdeliberately 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(recentEntriesquery) ordered by realcreated_at DESCwithout 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: whenopts.simulated_atis set, scope the query tocreated_at < simulated_at. (2)persona.last_entry_atwas 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 advancelast_entry_atforward (max(persona.last_entry_at, simulated_at)); live runs keep setting to now. - Episode bucketing fix (script regen after compressed-time):
bucketEntriesByEpisode()inseason-script.tswas 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 whosetriggered_atis the most recent one ≤ the entry'screated_at. Refactored to take full episode rows (withtriggered_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."
CompressOptionsaccepts 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 forcesauto_trigger_episodes=true(the mode is meaningless without it), caps the trigger schedule atmin(requested, untriggered.length), surfacesepisode_cap_warningwhen the cap kicks in. Serviceseason-compress-time.tscomputes the date range fromepisodes × days_per_episodeending today; one episode trigger perdays_per_episodeinterval. 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.ts—runCompressedSimulation(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'spreferred_times, and callsgenerateJournalEntry()withsimulated_atto back-datecreated_at. Whenauto_trigger_episodesis 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()gainedopts: { 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). Thetesting_markersflag 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'screated_atagainst what the entry actually says (e.g. an entry dated Tuesday should naturally mention "Tuesday" more often than chance). RoutePOST /:id/compress-simulation(super-admin, fire-and-forget, tracked underfull_pipelinestage) accepts the full options bag. UI: a⏩ Compress timebutton in theEpisodesAdminModalheader 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 innarrator-personas.ts(observational,sardonic,earnest,clinical,hardboiled, etc). The persona'svoice_brief+tics_to_avoidare concatenated into the script-generation system prompt inseason-script.ts, so changing persona produces different word choice, sentence rhythm, and attitude on next📝 Generate Script. Surfaced as the🎙 Narrator personadropdown 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 theVoiceEditorModal(✏️ Edit voicesfrom 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 inseason-audio.tsVOICE_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 =
hardboiledAND 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.
- Narrator PERSONA (
- 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. Serviceseason-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.tsdoes a top-of-function dispatch onseason.mode; novel mode short-circuits to the dedicated generator. Plan-side:story-seasons.tsgenerateSeasonPlanacceptsmode: '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 endpointGET /:id/chaptersreturns 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 onstory_episodes.chapter_prose(migration114_episode_chapter_prose.sql) AND mirrored intoseason_audio_segments(segment_type'journal_entry'reused as the prose body chunk;episode_introfor 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 tostory_episodes.chapter_prose, then rebuildsseason_audio_segmentsfor 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 honoropts.forceto overwrite cached prose with a fresh pass. Routes:POST /:id/generate-next-chapter(no body needed) andPOST /:id/episodes/:n/generate-chapter(body{ force?: boolean }) — both super-admin, synchronous (~5-15s). UI: inEpisodesAdminModal.tsxnovel-mode seasons get a📖 Generate next chapterbutton in the modal header and a✍ Generate prose/↻ Regenerate prosebutton on each triggered episode row, plus aProse ✓/No prosebadge 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.sqladdsstory_episodes.chapter_audio_url,chapter_audio_duration_seconds,chapter_audio_rendered_at). Two paths populate them: (1) The full-season render (season-audio.tsrenderSeasonAudio) groups already-rendered local segment files byepisode_numberand stitches each group intoaudio/${seasonId}/chapters/ep-${n}.mp3as a byproduct of building the mastercompiled.mp3. (2)season-novel-chapter-audio.tsrenderChapterAudio(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). HelpersttsSegment,probeDuration,uploadAudio,FFMPEGare now exported fromseason-audio.tsto 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 audiobutton per chapter inEpisodesAdminModal(only for triggered chapters with cached prose), plus an inline<audio>preview whenchapter_audio_urlis populated. TheGET /:id/chaptersendpoint now returnsaudio_url+audio_duration_secondsper 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 mastercompiled.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 webChapterPlaylistPlayervia a hidden<audio>element between auto-advancing chapters (frontend-only, no re-render needed when changed). Schema: migrations296_season_music_bumpers.sql+297_season_transition_sting.sqladdstory_seasons.intro_music_url/outro_music_url/transition_sting_url. Migration 306 adds per-slotplay_seconds(intro_music_play_secondsdefault 10,outro_music_play_secondsdefault 15,transition_sting_play_secondsdefault 5) so a 4-minute Suno upload is trimmed at render time (intro/outro via ffmpegatrim) or at playback time (sting via atimeupdatelistener on the audio element). Karaoke offset: the/:id/chaptersendpoint adds(intro_play_seconds - CROSSFADE_SECONDS)tointro_secondsso 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'saudio librarytags 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 byrequireSuperAdminat 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.sqladdsseason_story_bibles.lore_notes text+ the same onseason_story_bible_versionsso the timeline captures it.StoryUniverse.lore_notes(instory-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-novelbible.lore_notesis admin-curated (paste from NotebookLM exports, hand-written world docs, etc.). Rendering:buildBiblePromptBlockacceptsuniverseLoreNotesoption, 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 inbootstrapBibleFromPlanget a separate ~1200-token slice (richer context for initial bible writing). Bothseason-novel-chapter.tsgenerateChapterRawandseason-novel-script.tsgenerateNovelScriptnow look up the season's universe and passlore_notesinto the prompt block. UI: newLore notestextarea inStoryBibleModalEditor 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.sqladdsstory_seasons.universe text(nullable; null = standalone novel). Lore lives in code atstory-universes.ts— a typed registry ofStoryUniverserecords, each withcanon(characters / themes / settings / motifs / planted_threads / voice_rules / tone_rules / do_not_use) plus aprompt_directive(free-form instruction) and an optionaldefault_framework_key. First entry:turing_logs— Dr. Evelyn Harper (Senior Behavioral Architect at the Department of Cognitive Affairs, INTJ, 54, with asecret_knowledgearc 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:generateNovelSeasonPlanacceptsuniverse, persists tostory_seasons.universe, and injects the universe'sprompt_directiveinto the chapter-plan GPT call so chapter outlines fit the world.bootstrapBibleFromPlanreadsseason.universeand BOTH injects the canonical entities into the bootstrap GPT prompt as MANDATORY CANON AND merges them into the resulting bible directly (deduped bykey) — belt and suspenders so canon is guaranteed in version 1 even if the model skips one. Routes:GET /universes(super-admin list),POST /acceptsuniversefield 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, keysleep_story. Single-chapter shows (target_episode_count=1) walking the listener down into stillness — second-person present, slow sensory cadence, no plot. Universe registers achapter_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 newsleep_pacingcritic inscript-critics.tswired viaUNIVERSE_EXTRA_CRITIC_KEYSso the critique-and-iterate pass enforces the voice automatically. Generator atservices/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 runsgenerateSeasonPlan(mode='novel', universe='sleep_story', episodeCount=1) +autoRefineNextChapterfor take 1 + critics + take 2 + audio. Cost ≈ $0.71/story (gpt-4o-mini + ElevenLabs).runSleepStoryGenTick()is the cron-callable wrapper; wired intoapps/backend/src/index.ts(24h tick, 1 story/tick, env-gated default-off viaSLEEP_STORY_GEN_ENABLED, heartbeat assleep_story_gen_tickwith 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 flippingSLEEP_STORY_GEN_ENABLED). Frontend: new "Drift off" indigo-themed shelf on/graphene(self-hides until at least one published story exists;SleepStoryCardlinks straight to/seasons/[id]/readsince each show is one chapter) and theSleepTimerButtonon 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, keydeep_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 optionalStoryUniverse.commercials: CommercialFrame[]field — 18-entry library mixed acrosscda_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), andambiguous(Bonchère Almonds — 40-second close-up of an almond, no copy). Each commercial hasname,kind,product,pitch, optionalhidden_layer(counter-rev only — the smuggled detail that never gets named in prose), and optionaldirector_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:acceptsbuildBiblePromptBlockuniverseCommercialsand rotates a deterministic 6-entry subset per chapter (different slice each chapter so the writer sees varied options without blowing the prompt budget). Migration263_episode_layer_depth.sqladds nullablestory_episodes.layer_depth int(rendered as "🎬 Layer N" chip in the admin chapter-info tooltip on /read; defaults toepisode_numberfor 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 intoDEEP_CUT.commercialsfor 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 atadmin-veneer.tsmounted at/api/admin/veneer(super-admin-only via the samerequireSuperAdminhelper 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-stampspublish_dateto 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/draftaccepts{ issue_number, theme_hint?, model_key? }and calls— a single LLM call (cheap-tier default,llmDraftAndInsertIssuefeature_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-tintedhidden_layertextarea labeled "NEVER rendered to readers." Each ad form exposesasset_url+audio_urltext 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 foraudio_spotslots + 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 ownsgenerateMetadata(title"{title} — Veneer No. N", editor's-note excerpt description, canonical,openGraph/twitterwith a 1200×630 card,siteName: 'Veneer'); the gated signed-in experience moved verbatim into the client childVeneerIssueClient.tsx. The card is an edgeImageResponseat/api/og/veneer/[issue]— a vintage cover (cream#f4ecd8, hairline double-rule, VENEER masthead + tagline + "No. N" + title + framed cover plate whencover_image_urlis set; masthead-only fallback otherwise). BothgenerateMetadataand the OG route run server-side without a user token, so they read a NEW public, ungated meta endpointGET /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).paramsis awaited (Next 15). Verified with a fullnext build. -
Veneer Phase 3 — public gated routes + settings toggle (May 2026) — backend route
/api/veneer(mounted inindex.ts) exposes four endpoints, all auth-required:GET /statusreturns{ 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 /issuesreturns archive metadata (issue_number, title, cover_image_url, publish_date) only when caller is enabled, else 403 with{ gated: true };GET /issues/latestreturns the latest published issue + ads;GET /issues/:issueNumberreturns a specific issue by natural-key number. Per-user gate API at/api/user/veneer-preferences(GET + PUT) wraps the service module'sisVeneerEnabled/setVeneerEnabledand writes toprofiles.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 sharedVeneerIssueRendercomponent — 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 thehidden_layerfield (admin-only, the smuggled detail is the ad itself). Cold-visitor teaserVeneerColdTeaseris 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 pagedashboard/settingsas a new "Veneer" card slotted in after the Lovio card — theVeneerLeakPreferencesSectionfunction mirrorsLovioPreferencesSection'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.sqlcreatesveneer_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) andveneer_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 getupdated_attriggers via a localveneer_set_updated_at()plpgsql function. RLS pattern follows 279_llm_call_log.sql: super admins (whereprofiles.role='super_admin') get full access; authenticated users get SELECT on published issues + their ads only whencoalesce((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 ado $$ ... 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 moduleapps/backend/src/services/veneer.tsexposes typed read API for Phase 3:isVeneerEnabled(userId),setVeneerEnabled(userId, enabled),listPublishedIssues(),getPublishedIssueByNumber(n),getLatestPublishedIssue(). Admin CRUD (Phase 4) will land in a separateveneer-admin.tsonce 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/veneerwill 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.characterskeyjimmy— 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 onCommercialFrame—publication?: 'veneer',issue_number?: number,media_kind?: 'radio_copy' | 'print_poster' | 'placement_deck' | 'audio_spot' | 'print_ad'; (d) 6 hand-curated Issue #1 ads seeded intoDEEP_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_adsmigration, gated public/veneer/[issue]route with per-userveneer_enabledflag, 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 surfaces —
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 onDeepCutCharacterRotator/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]) whenseason.universe === 'deep_cut'. Registry lives atdeep-cut-character-media.ts— pose .json files inapps/frontend/public/deep-cut/. Renders nothing when registry is empty so the scaffold ships safely. Usesfilter: invert(1)+mix-blend-mode: screento flip the LottieFiles-default black strokes to white outlines that read against the dark#0a0a14/#060610backgrounds. 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-drivenImproveAllBehindBanner. Lets admins re-pull universe constants into an existing season without bumpingCURRENT_PLATFORM_VERSION(which would mark every season across every universe as "behind"). State-aware: each registered universe has acanon_revisionISO timestamp; the banner compares againstseason.created_atand renders either: (a) calm green "✓ Built on current canon" pill whencreated_at >= canon_revision, OR (b) rose "🎬 Refresh canon" CTA withlast_changessummary when older. Three-step refresh flow (chapter-replan opt-in via checkbox): (1) POST/bible/bootstrap with force=truere-runs bootstrap so new canonical characters/motifs/voice rules get merged in; (2) optional POST/regenerate-planvia new— locks chapter count, updatesregenerateChapterPlanForSeasontitle+event_description+hidden_details+dramatic_roleper chapter against the latest universe directive (triggered state + take history preserved); (3) POST/improve-all-behindwith newforce_all=truebody flag (added toimproveAllBehindChaptersopts) 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'sseason_story_biblesagainst 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 touniverse_canon_additions(migration 325). Overlay design: rather than editing universe canon in place (impossible for hardcoded code-constant universes liketuring_logs), additions live in a separate table thatgetUniverseByKeyfolds 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 whosekeyalready exists in the base canon array, so re-harvesting never duplicates.universe_canon_additionsis RLS-enabled no-policy. Button hidden unlessseason.universeis set. -
Pre-planning canon review — pull sibling canon in BEFORE a new story is planned (
UniverseCanonReviewModal+getUniverseCanonReviewincanon-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-reviewlists 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 candidatesPOST …/canon-review/promote→ reusespromoteCanonCandidates→universe_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 viagetUniverseByKeyon submit. GatedrequireSuperAdmin(creator OR super_admin), matching the harvest routes. -
"Refine to scope" — tighten a typed premise (
premise-refine.tsrefinePremiseToScope+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-shotclaude-haiku-4-5call (response_format:'text', feature_keyprompt_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. GatedrequireSuperAdminlike 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 useonMouseDown preventDefaultso the textarea'sonBlur={commit}doesn't save-and-exit before the click lands. -
"+ new show" end-to-end auto-flow (
NewShowAdminCard→autoGenerateShowInUniverse) — 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 universeprompt_directive, (3) inserts season + episodes. Fire-and-forget background portion (~90-120s, tracked via the samestartEventmechanism the manual Kick-It-Off uses so the SeasonClient's pipeline-progress strip lights up automatically): (4)merges universe canon into a fresh bible, (5)bootstrapBibleFromPlanfor 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 toautoRefineNextChapternarrative_mode('bounded'/'serialized'), not the schema-levelmodecolumn (which only accepts 'journal'/'novel'/'timelines' per migration 208 — auto-shows are alwaysmode='novel'). -
chapter_hard_rule+cut_landingcritic — 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) OptionalStoryUniverse.chapter_hard_rule: stringinjected at the VERY END ofcallChapterPromptGPT's system prompt (just before deltaSchema) — highest-attention slot the LLM reads last before generating. Universes without a hard rule see no change. (2) Newcut_landingcritic inscript-critics.tsreads 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). NewUNIVERSE_EXTRA_CRITIC_KEYSregistry maps universe key → critic keys that auto-fire for chapters in that universe;deep_cut: ['cut_landing'].autoRefineNextChapterlooks 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.tsnow picks a universe per scaffold via configurable envWRITER_FLEET_DEEP_CUT_PROBABILITY(default 0.25 = 25% of new fleet drops route to Deep Cut, 75% stay standalone).planPersonaSeasonaccepts an optionaluniversefield and PREPENDS the universe'sprompt_directiveto 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 (commit4c016aaf) was also fixed: LLM's "novel vs serial" choice now maps tonarrative_mode('bounded'/'serialized') instead of the schema-constrainedmodecolumn ('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. Migration321_prose_versions.sqlcreates 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 carriesprose_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. Helperservices/prose-versions.tsexportssnapshotProse()(best-effort insert; dedup on no-op except forsegment_overridewhere metadata change matters) andrestoreProseVersion()(snapshots current value first, so restore is itself undoable — same loop-of-history pattern asrestore-text). Wire points (the 5 audit gaps): (1)prose-leak-scanner.tssnapshots before each of the 3 in-place repairs; (2)season-novel-script.tssnapshots before the legacy persist write at chapter cache miss; (3)timeline-seasons.tssnapshots before path-aware chapter prose persist; (4)story-seasons.ts/segments/:segId/voice+/directionsnapshot the voice_id/voice_direction metadata change; (5)episode-takes.tspromoteChapterTake+season-takes.tspromoteTakesnapshot 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 unionsprose_versionswithstory_episode_takesdirectly. 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 tochapter_prosewithout re-promoting the take row). UI:📜 Versionsbutton in theSuperAdminPanel"More tools" cluster opensProseVersionsModal.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.tspublishSeasonToSpotify(): (1) mode-aware audio gate (compiledseason_publishings.audio_urlOR anystory_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 (setspodcast_published_at, creating theseason_publishingsrow 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. RoutePOST /api/story-seasons/:id/publish-to-spotify(super-admin, wrapped in apodcast_publishpipeline event). UI: green Spotify-glyph "✨ Publish to Spotify" button at the top of theSeasonDistributionPanelwith 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 (/graphenehero): driven byGRAPHENE.spotifyUrlinlib/graphene.ts— hardcoded default, overridable viaNEXT_PUBLIC_GRAPHENE_SPOTIFY_URL(Vercel), same brand-asset pattern ascoverImageUrl. Per-show (season page hero, public to all listeners): backed bymigration 322addingstory_seasons.spotify_show_url(nullable; submission is human-gated so the canonicalopen.spotify.com/show/<id>link can't be derived, it's pasted in). Admin sets it via a new URL input in theSeasonDistributionPanelwhich POSTs{ spotify_show_url }to the extendedPOST /api/admin/show-distribution/:seasonIdroute (admin.ts— now branches onspotify_show_urlin body vs. the platform-toggle body; validates the value is anopen.spotify.comlink). The public badge renders on/seasons/[id]only when the column is populated. Distinct fromspotify_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.tsbuildPodcastRss()now emits<itunes:image href="...">per<item>usingstory_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) NewSeasonDistributionPanel.tsxrenders 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 copyone-clicker hitting the existingPOST /api/story-seasons/:id/spotify-submission, and clickable Apple/Spotify submission-state chips that toggleapple_podcasts_submitted_at/spotify_submitted_atviaPOST /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 inSuperAdminPanel. 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 addsstory_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 localfinalize()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 toaudio/${seasonId}/chapters-dist/ep-N.mp3→ distribution columns. The CTA line is one shortttsSegment()in the narrator's voice (falls back toDEFAULT_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.tsbuildDistributionCtaText): 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 (noopts.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 inMusicBumpersModal.tsx(a "🎙 Podcast outro CTA" section — toggle + override textarea whose placeholder shows the live state-aware default) viaPATCH /api/story-seasons/:id/podcast-cta. Preview in the Share Kit (distribution_audio_previewfrom 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_tickhourly cron inapps/backend/src/services/prose-leak-scanner.ts, wired inindex.tswith 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) viastripJsonEnvelopeFromProse; (2)delta_suffix— trailing bible_delta JSON glued onto prose viastripBibleDeltaJsonFromProse(uses the expandedDELTA_SCHEMA_KEYSlist — addedcurrent_state,inner_life,secret_knowledge,expression_notes,sensory_palette,image_or_phrase,summary_so_farafter the 2026-05-23 leak on /seasons/47d8461f.../read showedcurrent_stateleaking inline + dangling}/]brackets that the original 278-era list missed); (3)meta_note— planning-language paragraphs ("Opening Image:", "[Note: ...]") viastripMetaNotesFromProse. Surfaces scanned:story_episodes.chapter_prose,story_episode_takes.prose,season_audio_segments.text_content,season_script_takes.segmentsJSONB. 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 toservice_errorswithservice='prose-leak-scanner',severity='warning', and metadata carryingsurface+episode_id+kinds[]+sample_prefix— so the daily/errorstriage 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_tickcron inapps/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 touchchanges_requestedorapprovedshows (those are explicit admin decisions, not abandonment). Rows aren't deleted; admin can restore viaPATCH /: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-healthsurfaces decay count + sample IDs per tick. -
Quick Review triage modal (May 2026) —
QuickReviewModalreplaces 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:Aapprove ·Xrequest changes ·Sskip ·J/↓next row ·K/↑previous ·Escclose. 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-fleetboth surfacefirst_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-fleetbatch-resolves the friendly display label for each distinct universe key in the response viagetUniverseByKey(covers DB + hardcoded universes). Response rows gainuniverse_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-statusnow surfacesreview_state,writer_display_name,writer_persona_id,writer_handle,created_atper hidden_season (joinsai_personaswhereis_platform_writer=trueonowner_user_id). New backend endpointPOST /api/story-seasons/bulk-review(super-admin) accepts{ season_ids: string[], state }and loops the existing/:id/reviewhandler internally so cascades (fill prose, generate poster, generate persona poses, auto-publish when ready) run identically per season. FrontendGrapheneClient.tsxsplitshiddenSeasonsintoawaitingByUniverse(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.tsgainsclaude-opus-4-7(premium, $15/$75 per M tokens) + fallback togpt-4o, plus two exported constants —PROMOTE_MODEL_KEY(Opus 4.7) andPLANNING_MODEL_KEY(Sonnet 4.6). Planning layer upgrade:knowledge-layer.ts,chapter-spine.ts, andchapter-stakes.tsnow route throughPLANNING_MODEL_KEY(was hardcodedgpt-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:promoteChapterTakenow defaultsupgrade_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 onPROMOTE_MODEL_KEYbefore locking and promotes that sibling instead. The original take stays asdraftso 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 atstory-seasons.ts:3441passesfalsebecause that path is unreviewed; the PATCH route atstory-seasons.ts:4590accepts 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 theirmodel_keyif a specific craft check underperforms. Seedocs/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".
busyflag now ties tocreatingNextEpisode || 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 bysawKickOffRunningRefso 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/:idand/chapterspayloads omitlive_take_platform_version(admin-only field), soisVersionBehind(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: newadminEpisodesLoaded(SeasonClient) andadminChaptersLoaded(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_dystopianframework (Black Mirror lane) — new entry instory-frameworks.tstuned for procedural dread. Intensity curve[0.25, 0.35, 0.55, 0.85, 0.7](late spike for the reveal, then trailing dread).prompt_directivecovers 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 theturing_logsuniverse (which sets it asdefault_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.sqladdschapter_listen_sessions.last_progress_seconds(replaced on every progress event, NOT ratcheted — distinct frommax_progress_secondswhich is the "deepest ever" signal used for completion tracking). Backfill copiesmax_progress_secondsintolast_progress_secondsfor existing rows. Servicechapter-listen-tracking.ts—recordListenEventnow writes both fields on every event; newgetResumeCursor(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_changedflag → optional "story has been updated" notice). Cross-device works for authenticated users automatically (rows keyed onuser_id); anonymous listeners use a per-browsersession_idso 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).ChapterPlaylistPlayeracceptsinitialChapterNumber+initialPositionSecondsprops — selects the matching chapter on mount, then seeks the audio element to the resume position once metadata loads (capped atduration - 1sso it doesn't auto-advance immediately). Resume is suppressed when the chapter is alreadycompleted=trueso listeners don't get bumped to the very end of a chapter they've finished. Page surface: small inline↻ Resuming Ch3 · Title at 4:23hint 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.sqladdschapter_listen_sessions(one row per(identity, episode, take)combo; identity is authenticateduser_idor anonymoussession_idfrom the same localStorage UUID used by ratings; partial unique indexes enforce dedup;max_progress_secondsratchets up;completedflips at 90% threshold;play_countincrements on each new 'start' for replay signal). Servicechapter-listen-tracking.ts—recordListenEventupserts + ratchets max-progress (never decreases);summarizeListenStatsreturns 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 bodysession_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:ChapterPlaylistPlayeraccepts a newonListenEventcallback prop wired by/seasons/[id]/page.tsxto 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. Usesfetch({ keepalive: true })so the last event survives page-unload. Admin view: newListenStatsAdminModal.tsxopened by📊 Listensbutton inSuperAdminPaneltools 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← liveso 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.sqladdsseason_listener_ratings(one row per listener per season — identity is either authenticateduser_idor anonymoussession_idfrom 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). Serviceseason-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_publiclyflag that's true once count ≥ 5),listRatingsForAdmin(full list with feedback text for moderation). Routes:POST /:id/listener-rating(auth optional, anonymous via bodysession_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: newListenerRatingModal.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 whenChapterPlaylistPlayerfires its newonPlaylistEndedcallback (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 whenshow_publiclyis true (count ≥ 5) to avoid small-sample noise. Admin view via newListenerRatingsAdminModalopened by⭐ Ratingsbutton inSuperAdminPaneltools 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 keyhj_listener_session_idenables 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 callingautoRefineNextChapterwith the newskipAudio: trueoption — 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, callingrenderChapterAudiofor each. Skips chapters that already havechapter_audio_urlunlessforce: true. Per-segment TTS cache reuse (commit14e287a) keeps cost low on re-runs. Routes (super-admin, fire-and-forget, pipeline-event tracked):POST /:id/auto-refine-all-chapters(body{ critic_keys? }) andPOST /:id/queue-all-chapter-audio(body{ force? }). UI: two new bulk buttons inEpisodesAdminModalheader 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 aggregatorlistChapterCritiqueHistory(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 inChapterTakesModal. 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 showpromoted Xh agoin 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.tsautoRefineNextChapter(seasonId, opts)orchestrates: (1) find next untriggered chapter, (2) trigger, (3)generateChapterTakefor take 1, (4) runDEFAULT_CRITIC_KEYS(tension_editor / dialogue_doctor / repetition_hawk) in parallel against take 1, (5) auto-apply every finding viarecordChapterCritiqueDecision({ action: 'apply' }), (6) auto-approve any pending bible deltas from take 1 (canon advances), (7)generateChapterTakefor take 2 withapply_critiques_from_take_id = take1.idso the critic notes fold into the new prompt, (8) auto-approve take 2's deltas, (9)promoteChapterTakefor take 2 → chapter_prose updates + segments rebuild, (10)renderChapterAudio→ per-chapter MP3 withcache_segmentsreuse 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 underfull_pipelinestage so it shows in the runs feed). Body accepts{ critic_keys?: string[] }to override the default set. UI: new✨ Auto-refine next chapterbutton inEpisodesAdminModalheader — 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'schapter_audio_urlpopulating on the season page playlist (commitb3a7d19'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.mp3step 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(inseason-audio.ts) detectsseason.mode === 'novel'and skips the ffmpeg concat + Supabase upload of compiled.mp3. The per-chapter byproduct phase (commit262db32) becomes the primary output: eachstory_episodes.chapter_audio_urlgets stamped, andseason_publishings.audio_urlstays null (signals "playlist mode" to the front end).season_publishings.audio_duration_secondsis still populated, summed from per-chapter durations, so total-runtime UIs keep working. New componentChapterPlaylistPlayer.tsx— sequential<audio>playback with auto-advance ononEnded, 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 whenseason.mode === 'novel'AND there are chapters withchapter_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 optionalpronounsstring toBibleCharacter(story-bible.ts).buildBiblePromptBlockleads 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 (⚥ Pronounsbutton in the sticky header, gated oncanMarkup= owner OR super-admin) and in the character editor (StoryBibleModalheader). Routes (creator/super-admin viarequireSuperAdminwhich acceptsis_creator):GET /:id/characters(key/name/pronouns projection),PATCH /:id/character-pronouns {key, pronouns}, andPOST /: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;backfillBibleFromChaptersnow 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-pronouns→pronoun-fix.tsfixChapterPronouns, a constrainedclaude-sonnet-4-6copy-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; writeschapter_prose+ re-renders audio with segment-cache reuse, mirroringrepair-prose) — and ↻ Re-render Ch N, the heavier full regeneration (POST /:id/episodes/:n/generate-chapter→autoRefineNextChapteron the existing chapter). The modal receivescurrentChapter= the reader'snowReadingChapter. Use Fix for "just correct the pronouns," Re-render for "rewrite the chapter." -
Bible character dialogue scaffolding — extends
BibleCharacterwith optionalmbti(4-letter),age,verbal_tics(things they DO say),speech_avoid(things they DON'T say),backstory(paragraph-length formative context), andrelationships(per-other-character speech dynamics). All additive on the JSONB column — older bibles parse fine. The chapter prompt'sbuildBiblePromptBlockrenders these inline per character: identity line showsMara (INTJ, 34, intro Ch1), dialogue fields appear asspeaks like:/avoids:after the existingvoiceline, andbackstory:+relationshipsare 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 acceptsupdate_characters[].verbal_tics / speech_avoid / relationshipsso the model can sharpen speech patterns mid-novel as it observes how a character actually talks under stress. UI inStoryBibleModalCurrent tab shows MBTI as a cyan chip and renders the new fields with their ownFieldLinerows. 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 optionalai_persona_idper-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/generateNextChapterfromseason-novel-chapter.ts, which wrote directly tostory_episodes.chapter_prose. Both routes now orchestrate via the takes layer — they callgenerateChapterTakefollowed bypromoteChapterTake, leaving a take row + bible delta + (when iterating) consumed critique decisions behind every prose change. Behavior to callers (the existing📖 Generate next chapterand✍ Generate proseUI 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 legacygenerateChapter/generateNextChapterfunctions 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 toChapterTakesModalheader showing total takes, cumulative cost, and cumulative latency for this chapter (excludes syntheticlegacy_v1rows 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, soAuditor PellbeatsPellwhen 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 addsseason_character_voiceskeyed on(season_id, character_key)withvoice_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. Servicecharacter-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)returnsVoiceSpan[]alternating narrator + character voices. 60s in-memory per-season cache mirrorspronunciation-lexicon.tsso 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) andseason-audio.tsrenderSeasonAudio(segment render — journal-mode / script-take path). Same integration shape in both: per-segment TTS step callssplitProseByVoicepost-lexicon, then for single-span output uses the fast path (onettsSegmentcall as before), for multi-span output runsPromise.allparallel TTS per span and concatenates via thehelper (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 onconcatMp3Buffers/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. UICharacterVoicesModal.tsxopened via🎭 Voicesbutton inSuperAdminPaneltools 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 fromclip_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 onstory_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 onstory_seasons.video_idea_queue(migration 328) viaPOST /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 fromstory_episodes.pullquotesinto 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 aposted_at: { tiktok, reels, shorts, bluesky }object — per-card pill row toggles the timestamp viaPATCH /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 whoseclip_sourcereferences 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 + agraphene.fmwatermark 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 toseason-assets/<seasonId>/video-ideas/idea-<index>.mp4. URL stamped onto the idea'srendered_video_url; the card swaps the button for an inline<video>preview + a Download MP4 link. ReusesgetFontPath+ the sanitization/storage patterns fromseason-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+ extendedbskyPostincafe-bluesky.ts): rendered ideas get a "🦋 Post to Bluesky" button that uploads the MP4 via Bluesky's async video pipeline (app.bsky.video.uploadVideo→ pollgetJobStatusuntilJOB_STATE_COMPLETED, ~30-60s), then creates a post with a nativeapp.bsky.embed.videoembed (9:16 aspect, alt = on-screen text). Stampsidea.bluesky_post_uri+idea.posted_at.blueskyon 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. NewBSKY_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-feedtoggle); (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 asPaidTrafficUrlPanel); (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/idlanding URL baked in. Taggedutm_medium=social(NOTcpc) so these free comment clicks never blur into paid-ad attribution, plus a distinctutm_sourceper 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 sameslugify+show-{slug}campaign convention as the paid variants. Solves the trap where reusing a paid/listenUTM 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 — plusPOST /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 fromSuperAdminPaneladmin 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]/analyticscovers it), email/Discord share buttons (manual copy works for low volume). -
EmberKiln invite email + public
/studio/newupload route — the two missing pieces between "admin grants creator" and "creator actually uploads a book." New transactional email instudio-notifications.ts—sendStudioGrantEmail({ email, name?, current_manuscript? })fires the moment the grant route flipsis_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'snamefirst-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 existingsafeResendSenddeliverability 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/newfor granted creators — gives them a Studio-branded URL instead of/dashboard/admin/.... Both routes mount the same client component, extracted tocomponents/manuscripts/ManuscriptUploadClient.tsxwith a new optionalseasonRouteForprop (defaults to/dashboard/admin/seasons/<id>— when a Studio-branded/studio/seasons/<id>page ships,/studio/newcan override the redirect target without changing the backend or the upload flow). Backend authorizes both routes via the existingrequireSuperAdminmiddleware which already acceptsis_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 manuscriptCTA, 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 (Setup→Drafting→Rendering→Audio ready→Exporting→Published) + render-progress bar. Empty state points to /studio/new + the 90-second tour + the FAQ. Backendstudio.tsaddsGET /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 }whereusageis the existinggetMonthlyUsageForUserresult (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). SingleIN (...)query for chapter aggregates across all seasons in one round-trip (no N+1). In-product links:StudioFooter(inStudioChrome.tsx) addsYour audiobookslink betweenStudioandPricing;SuperAdminPaneladmin 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_PASSWORDto auto-post contest bento + winners).Migration 293addsstory_seasons.bluesky_autopost_enabled(opt-in per show, default false) +story_episodes.bluesky_post_uri+bluesky_posted_at. Serviceseason-bluesky.tspostChapterDropToBluesky({ 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-exportedbskyPosthelper from cafe-bluesky.ts, and stampsbluesky_post_urifor idempotency (re-fire is a no-op unlessforce: true). Auto-fires fire-and-forget on the success path ofPOST /:id/trigger-episodewhen the show has opted in — so every chapter trigger drops a free post into Bluesky for compounding distribution. Routes:PATCH /:id/bluesky-autopostbody{ enabled: bool }(per-show toggle),POST /:id/episodes/:n/post-to-blueskybody{ force?: bool }(manual trigger for back-fill / redo). UI: new collapsed-by-defaultBlueskyAutopostPanelon(super-admin only), sibling to/seasons/[id]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 whenis_published_to_graphene=false(chapter link would 404) orepisode.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 294addsbluesky_likes+bluesky_reposts+bluesky_replies+bluesky_engagement_checked_atonstory_episodes. NewgetBskyPostEngagement(uri)helper incafe-bluesky.tscallsapp.bsky.feed.getPostThreadand returns{ likes, reposts, replies };refreshChapterEngagement(episodeId)andrefreshAllRecentBlueskyEngagement()live inseason-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 cronbluesky_engagement_sweep_tickinindex.ts(heartbeat-tracked,CRON_LABELSentry added). Always stampschecked_aton 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 viaGET /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 printstrips on-screen-only chrome (header buttons, surrounding gradient) sowindow.print()produces a clean one-pager. Server-rendered inline SVG QR via theqrcodenpm package (errorCorrectionLevel M, margin 2, width 240, dark#0f0a1fon white) — print-quality stays sharp at any size, no client JS needed. Standalone QR endpointGET /api/story-seasons/:id/qr-code.svg(routes/story-seasons.ts) serves a 512px-wide SVG (same QR shape, sticker-printer DPI sweet spot) withContent-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(owner/admin gated, sibling to/seasons/[id]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. HelpersendStudioRenderCompleteEmailtakes{ season_id, kind, detail, succeeded }; resolves the season →owner_user_id→profiles.email. Multiple safety bands: missingRESEND_API_KEYskip, 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'stracker.complete()success path — bad email never disturbs the render pipeline. Email body uses the same trackerdetailstring (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 whenNEXT_PUBLIC_STUDIO_DISCORD_URLis 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. Backendstudio-notifications.tsaddssendStudioSupportEmail({ from_email, from_name?, message, topic, user_id? })— sends tosandon@hivejournal.comwith 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. RoutePOST /api/studio/support-request(studio.ts) — usesmaybeAuthenticateUserso signed-in users get theiruser_idattached 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(inStudioChrome.tsx) gains aHelplink betweenFor creatorsandTermsso every public Studio page surfaces it;SuperAdminPanelgets a subtle? Helplink in the admin header row next toAll 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
StudioChromeheader + 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.tsxgains an optionalrequireRightsConfirmationprop 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 chromeStudioChrome.tsxexportsStudioHeader(brand + Pricing / Listen / Join the beta nav),StudioFooter(Studio / Pricing / For creators / Terms / Privacy / Listen links), andStudioShell(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'screated_atacts as de facto acceptance timestamp for Phase 1 closed beta. When paid pricing arrives in Phase 2, add aprofiles.studio_tos_accepted_at+studio_tos_versionfor 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
ttsSegmentthat throwsTtsQuotaExceededErrorif 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. Schemamigration 289— addsprofiles.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 —requireSuperAdmindenies 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. Servicetts-spend.tsaddsgetMonthlyUsageForUser(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'sowner_user_idonce at function entry and threads it into thecontext.user_idfor 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: thePOST /api/studio/beta-signups/:id/grant-creatorroute (studio.ts) now also stampstts_monthly_char_limit = DEFAULT_CREATOR_MONTHLY_CHAR_LIMITwhen 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. UIPreflightAudioModaldetects 429 + structuredcode: 'tts_quota_exceeded'in the response body, rendersQuotaExceededPanelwith usage bar (used / limit) + rolling-window explanation + "reply to your welcome email for a higher cap" hint.RenderAllChaptersModalgets quota handling for free: the bulk render's per-chapter isolation catches the QuotaExceededError, stampschapter_audio_last_errorwith 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-emailcould 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_LIMITwas 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_writerpersona accounts (writer-fleet-owned shows) resolve toPLATFORM_WRITER_TTS_MONTHLY_CHARS(env, default 50_000) instead of 0, so fleet audio renders bounded. Resolution order ingetMonthlyUsageForUser: super_admin (null/unlimited) → explicittts_monthly_char_limit→ is_creator (20k) → platform-writer (50k) → else 0. Alimit === 0now 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/userprobe readscharacter_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 routePOST /api/studio/beta-signups/:id/grant-creator(instudio.ts) looks up the user by the signup's email via the same paginatedauth.admin.listUserspattern asadmin.ts:role-grants/by-email, upsertsprofiles.is_creator=true, and advances the signup tostatus='onboarded'withonboarded_atstamped (also back-fillsinvited_atif the signup was still in pending/reviewed — granting implies invitation). Returns a structured 404 withsignup_emailwhen 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 existingrequireSuperAdminmiddleware on every audiobook-suite route already acceptsis_creatorusers (seestory-seasons.ts:120— the gate is broader than the name suggests; mirrored inline in studio.ts). Flippingis_creator=trueimmediately unlocks every audiobook route — manuscript ingest, lexicon, character voices, audio preview, bulk render, ACX export, the overview card — without any further middleware changes. UIstudio-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 usingtts_call_log), transactional invite emails so granting a beta sends the user a "you're in" message + signup link automatically, pricing page, public/studio/newfor 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.tsxfor 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 fromutm_source/?source=/document.referrer. Sibling to/studio/welcome(90-second tour for already-granted creators) —/studiois for visitors who DON'T yet have access. Schema (migration 288):studio_beta_signupskeyed onemail(unique — re-submitters update their record),statuslifecycle (pending→reviewed→invited→onboarded→live; failed:declined/archived) +invited_at/onboarded_atauto-stamped on status transitions. RLS: public INSERT (form submissions work without auth; route layer enforces shape) + super-admin SELECT/UPDATE/DELETE. Routes in newstudio.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/studioinapps/backend/src/index.ts. Next in Phase 2: ungate audiobook routes to a paid-user tier, quota enforcement usingtts_call_logspend data, public/studio/newmanuscript 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 newtts_call_logtable (migration 286) so we can finally price audiobooks, enforce quotas, and surface cost-per-book to creators. Previously only LLM costs were tracked (viallm_call_log); ElevenLabs TTS — the bulk of audiobook production cost — was untracked. Servicetts-spend.ts—logTtsCall(input)(best-effort, swallows failures so telemetry never breaks a render) +getSeasonSpend(seasonId)aggregator +TTS_COST_CENTS_PER_KCHARconstant (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 4thcontextparam ({ 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 withseason_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 nullfor per-season rollups,(created_at desc)for time-window dashboards,(user_id, created_at desc) where user_id is not nullfor the Phase 2 per-user billing reads. AudiobookOverviewCard integration: the existingGET /:id/audiobook-overviewendpoint now returns aspend: { total_cost_cents, total_character_count, call_count, by_feature: [{ feature_key, cost_cents, character_count, call_count }] }field; the card renders a greenTTS: $X.XXchip 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/newungate. -
🎙 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.ts—getAudiobookOverview(seasonId)fans out parallel queries acrossstory_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 taggedready(✓ emerald),in_progress(… amber), ortodo(○ gray) with a one-line detail. RouteGET /api/story-seasons/:id/audiobook-overview(super-admin). UIAudiobookOverviewCard.tsxrenders 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 + whenrefreshKeyincrements (SuperAdminPanel passes itsrunsRefreshKeyso 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
renderChapterAudiopipeline; per-chapter isolation means a single failed chapter doesn't abort the batch. Serviceaudio-bulk-render.ts—bulkRenderChapters(seasonId, { only_unrendered?, include_untriggered? })+listChapterRenderStatus(seasonId). Status derivation reads from the durablechapter_audio_*columns (migration 228—chapter_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) andGET /: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 }). UIRenderAllChaptersModal.tsxopened via🎬 Render allbutton inSuperAdminPanel(novel mode only, between🎧 Preview audioand📦 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 existingPOST /:id/episodes/:n/render-audioroute withforce: true. Mobile-aware scroll. While the batch is active, the modal polls every 4s; auto-stops polling when no chapter is inrenderingstate (the batch completed). Why a discrete bulk render route exists alongsiderenderSeasonAudio(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 toclaude-haiku-4-5at temperature 0.2 — ~0.5-2¢ per scan. Servicelexicon-autosuggest.ts—autoSuggestLexicon(seasonId, { prose_scan_limit?, model_key? }). Candidate sourcing in priority order: (1) bible characters withvoice_brief/physical/backstorycontext attached so the LLM can pick a culturally-grounded pronunciation; (2) bible settings + sensory_palette/rules; (3) bible motifs (only when theirimage_or_phraselooks like a proper noun, not a phrase); (4) prose scan top-N by frequency, capped atprose_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 likeDr/Mrs), enforces min 3 chars. LLM-side filter: terms common English readers handle correctly (John,London, etc.) are returned in askipped_commonarray surfaced to the admin as a count, not as suggestions. SiblingbulkAddLexiconEntriesaccepts the admin-edited subset; per-row failures don't abort the batch —failed[]reports collisions/malformed-IPA inline. RoutesPOST /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 entriesbutton next to+ Add entryinPronunciationLexiconModal.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 showsAdded 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 ElevenLabsVOICE_BANKwith gender/age/accent/mood metadata + validVOICE_DIRECTIONSpreset keys, asks the model to match each character to a voice with a one-sentence rationale grounded in their bible entry. Defaults toclaude-haiku-4-5(low-creativity classification task) at temperature 0.2 — ~0.5-2¢ per cast. Servicecharacter-voice-autocast.ts—autoCastCharacterVoices(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 todefaultdirection 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 existingupsertCharacterVoiceso 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 }. RoutePOST /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) andreplace_existing=true(re-casts everything, frontend confirms first). UI: new amber toolbar at the top ofCharacterVoicesModal.tsxwith two buttons —✨ Fill unassigned(safe default) andRe-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.tsreuses the exact production pipeline pieces —applyLexiconToText→splitProseByVoice→ parallelttsSegmentper 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) andPOST /:id/preview-audiowith body{ episode_number?, max_words? }— returnsaudio/mpegbinary 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 audiobutton inSuperAdminPaneltools row between🎭 Voicesand📦 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.ts—generateSeasonKindleEpub(seasonId)builds an in-memory EPUB 3 zip (mimetypeSTORED-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 fromstory_seasons.poster_url(JPEG/PNG only; webp + others silently skipped — KDP rejects). Author fromprofiles.full_nameof the season owner, falling back to "Graphene Author" so we never ship a missingdc: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 mirrorscafe-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. RouteGET /api/story-seasons/:id/kindle-export(super-admin gated) — synchronous, returns the.epubbinary withContent-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 .epubActionButton 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.ts—generateSeasonPrintPdf(seasonId)mirrors the Kindle epub's chapter fetch (story_episodestriggered + non-emptychapter_prose, author fromprofiles.full_name), builds a typeset book interior via the pure, unit-testedbuildPrintBookHtml()(title page + copyright page + per-chapter sections, justified serif body, chapter page-breaks, first-paragraph flush-left), and renders via newrenderBookPdf()inpersona-browser/pool.ts— drives trim (6×9) + margins + a centered page-number footer entirely through Playwright'spage.pdf()options (vsrenderHtmlToPdf's paper-format path). RouteGET /api/story-seasons/:id/print-export(super-admin gated), synchronous, returns the PDF binary withX-Print-Chapters/X-Print-Word-Countheaders. UI:🖨️ Export Print PDF (6×9)ActionButton in SuperAdminPanel next to the Kindle/M4B exports (novel mode; same auth-gated blob-download trick). Golden testtests/print-export.test.tslocks the HTML builder + paragraph escaping. Phase 1 (Lulu Print API — in-app ordering):services/lulu.tswraps OAuth2 client-credentials (cached token) + cost calculation + print-job creation + status, gated byisLuluConfigured()(envLULU_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 viacountPdfPages()(pdf-parse), derives the spine (spineWidthInches()— 444 PPI for the 6×9 BW package0600X0900BWSTDPB060UW444MXX), and renders a full-wrap cover (buildCoverHtml()— EmberKiln teal lockup, back+spine+front). Routesroutes/print-orders.ts(creator-gated, mounted/api/print-orders):POST /:seasonId/cost(live price),POST /:seasonId/order(render → upload both PDFs to the publicseason-assetsbucket viauploadPdf()→ create Lulu job → persist),GET /+GET /:orderId(status syncs from Lulu on open).Migration 419print_orderstracks each order + Lulu job id + status + cost/address snapshots. UI:📦 Order print copiesbutton →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_synccron (every 15min,services/print-order-sync.tssyncInFlightPrintOrders()polls open orders' Lulu jobs; heartbeat + cron-toggle + label; no-op when Lulu unconfigured); (2) Lulu webhookPOST /api/print-orders/webhook(raw-body HMAC viaLULU_WEBHOOK_SECRET, mounted before globalexpress.json()like the Stripe webhook;verifyLuluWebhook/extractWebhookJobin 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.tsxonReadClient).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 apending_paymentrow → Stripe Checkout session),fulfillReaderPrintOrder()(renders + places the Lulu job on payment, idempotent; on failure →paid_unfulfilled+ critical error). RoutesPOST /api/print-orders/:seasonId/retail-quote+/checkout(authed reader, not creator-gated). Fulfillment rides the existing Stripe webhook (routes/payment.tskind:'print_order'branch →checkout.session.completed).Migration 420addskind/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 notebook —generateNotebookPrintPdf(notebookId)in print-export.ts fetches the notebook'sjournal_entries(oldest→newest) and maps one entry → one chapter via the pure, deps-free, unit-testednotebookEntriesToPrintChapters()innotebook-prose.ts(graphic-novel entries render their panels to prose viaentryToChapterProse, 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). RouteGET /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 testtests/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 optionallineageso 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_ordersnow allows anotebook_idin place ofseason_id(migration 439— nullableseason_id, newnotebook_id, CHECK exactly-one; the adminGET /list joinsnotebooks(name)). UI: a 📦 Order printed copy button beside the Print-ready PDF one, opening the (now generic — takes anendpointBase)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 430addsstory_seasons.theme_mood+theme_accent.resolveSeasonTheme()returnsisThemed:falsefor the default so unthemed seasons are pixel-identical (no regression); when themed it paints the mood gradient as the page root's ownbackground(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 inSeasonClient+ thereader(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 fromvisual_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-5from the premise), and optionally the author's pen name over the art (a dedicatedprofiles.pen_name,migration 429, defaulting tofull_name; editable inline in the studio — which persists it to the profile — and from/dashboard/settingsviaPUT /api/user/profile). Composed server-side (HTML → PNG via the shared Chromium pool's newrenderHtmlToPng), 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-faceagainst 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 428addsposter_template/poster_tagline/poster_show_credit/poster_show_on_page/poster_overlay_url/poster_overlay_built_attostory_seasons. Routes (owner/super-admin gated by therouter.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 showsposter_overlay_urlinstead of the bare cover. Regenerating the cover invalidates the cached overlay (nullsposter_overlay_url/_built_at+ clearsposter_show_on_page, keeping template+tagline for a one-click re-save), alongside the merch moderation reset inseason-media.ts. Print integration (shipped):fulfillMerchOrder(merch.tsresolvePrintAsset) 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).MoviePosterStudiotakes an optionalepisodeNumber; 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 overstory_episodes.chapter_cover_image_urland persisting to thechapter_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 sharedimage-clipboard.tsutil (cross-origin fetch-to-blob + Safari-safe ClipboardItem promise). The same util powers a 🎬 Poster · share image card in theshare kitthat surfaces the styledposter_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.tsGET /api/admin/share-kit/:seasonId) now selectsposter_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.tsensurePrintAsset()): 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 toseason-assets/print/<kind>-<id>-4x.png(once per cover, not per order; gated onREPLICATE_API_TOKEN).gelato-client.tswraps Gelato Order API v4 (isGelatoConfigured/createGelatoOrder/getGelatoOrder), inert withoutGELATO_API_KEY.merch.ts: Phase-0 product catalog (code constant),quoteSelfMerch()(at-cost: base + shipping, margin 0),createSelfMerchCheckout()(Stripe Checkoutmode:'payment'collects the shipping address),fulfillMerchOrder()(webhook → upscale → place the Gelato order, or park atawaiting_fulfillmentwhen Gelato is unconfigured; on failure →paid_unfulfilled+ critical error, mirroring print-storefront). Routesroutes/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.tsnewkind:'merch_order'branch).Migration 426merch_orders(image-shaped, modeled onprint_orders) + nullablecreator_revenue_splits.merch_creator_pct(Phase-1 payout split). UI: owner-onlyMakeItRealCTA.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 generatedposter_urlexists (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 427adds'merch_revenue'to thecreator_earnings.source_kindCHECK + moderation-cache columns onstory_seasons. Pure money math inmerch-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 moderationmerch-moderation.tsensureMerchSellable()(gpt-4o-mini vision: rejects recognizable real faces / brand logos / trademarked chars; verdict cached on the season; fails open on infra error). merch.ts addsassertPublicSeason(gate onis_publicly_viewable),getSeasonStorefront,createFanMerchCheckout(guests via soft auth, moderation-gated,422 merch_cover_rejectedon block), andbookMerchEarninginsidefulfillMerchOrder→creator_earningsrow (source_kind='merch_revenue', statuspending, split viamerch_creator_pct??MERCH_DEFAULT_CREATOR_PCT=50; at-cost self-orders no-op). RoutesGET /:seasonId/storefront(public) +POST /:seasonId/checkout(soft auth). Fulfillment cronmerch-order-sync.tssyncInFlightMerchOrders()(themerch_orders_synccron, 15min, heartbeat + toggle, no-op without Gelato). UIFanPosterStorefront.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):MerchProductgains akind(poster|apparel|drinkware) andMERCH_PRODUCTSadds 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-costMakeItRealCTA.tsx) became product pickers. Sell-gate: every merch sell path (both checkouts + the product/storefront list endpoints) gates onisGelatoConfigured()(checkouts 503, lists empty/404 → CTAs self-hide) so we never take money we can't fulfill;awaiting_fulfillmentparking only covers a transient Gelato outage after it's wired. Marketing (positioned as the next "every form" output): a Merch chip inEmberkilnFlow(/emberkiln), a/featuresentry, 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 aspending; 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 (astory_seasonsrow 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. Mirrorswelcome-drip.ts(15-min cron, ResendsafeResendSend, 50/tick cap) and reuses the welcome-drip marketing opt-out (welcome_emails_optout+ the same HMAC/api/welcome/unsubscriberoute — one marketing opt-out, not two). Idempotent per season viastory_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 withoutRESEND_API_KEYor when Gelato is unconfigured; OFF by default (envPOSTER_OFFER_DRIP_ENABLED+ theposter_offer_dripcron toggle) since it sends real marketing email. Cron:poster_offer_dripinCRON_REGISTRY+poster_offer_drip_tickheartbeat. 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 stampposter_winback_sent_at(migration 433) so a story can get both. Never emails the autonomous fleet (skipsis_platform_writerowners). Gated by its OWN flagPOSTER_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 284addsacx_export_url,acx_export_rendered_at,acx_export_status(idle/rendering/failed),acx_export_error,acx_export_duration_seconds,acx_export_size_bytesonstory_seasons. Render pipelineacx-export.ts: pulls all triggered chapters in order with their existingchapter_audio_urls → downloads each MP3 to a temp dir → loudness-normalizes via ffmpeg'sloudnorm=I=-20:TP=-3:LRA=11(middle of ACX's -23 to -18 LUFS window, -3dB true peak ceiling) → builds a;FFMETADATA1chapter-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 atacx/<season_id>.m4b(content-typeaudio/mp4). Temp dir cleaned up in afinallyblock even on failure. RoutesPOST /api/story-seasons/:id/acx-export(fire-and-forget — sets status='rendering' + clears prior error, kicks offvoidbackground render, returns 202; render duration is 30s-5min depending on book length, well past any proxy timeout) andGET /:id/acx-export(status poll — returns{ url, rendered_at, status, error, duration_seconds, size_bytes }). Failed renders write toservice_errorsso the daily/errorstriage flow + Marlow's Sunday ops report surface them. UIAcxExportModal.tsxopened via📦 Export M4Bbutton inSuperAdminPaneltools 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_urlon 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 (inepub-parser.ts) priority: (1) EPUB 3 manifest item withpropertiescontainingcover-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 withid="cover"andmedia-typestartingimage/. DOCX cover detection (indocx-parser.ts) readsdocProps/thumbnail.jpeg(or.jpg/.pngfallbacks) — Word auto-generates this on save when "Save Thumbnail" is on (default in recent versions). PDF cover uses pdf-parse v2'sgetImage({ partial: [1], imageThreshold: 200 })to extract embedded images from page 1, then picks the largest bywidth × 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/canvasis a heavy dep for the small affected slice. Cover bytes flow ascover_image_base64+cover_image_mimethroughEpubParseResult→ /parse response → preview UI → /create-season body → newhelper (sibling touploadCoverImage(prefix, buffer, mime)uploadImage, MIME-aware so JPEG covers don't get labeled as PNG) → Supabase Storage →poster_urlset inline on the season insert. UI: preview stage of/dashboard/admin/manuscripts/newshows 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
.pdfto the manuscript-ingest trifecta alongside EPUB + DOCX. Newpdf-parser.tsuses 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 IRoman numerals,Part N, standalonePrologue/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 inmanuscript.tsextends from 2-way (.epub/.docx) to 3-way (.epub/.docx/.pdf); sameEpubParseResultshape 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 viapdfjs-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
.epubor.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-specificdocx-parser.tsis a sibling to the EPUB parser — same pure-Node approach withadm-zip(DOCX is also a zip; Office Open XML). Readsword/document.xmlfor body,docProps/core.xmlfor 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 inmanuscript.tsroutes by file extension —.docx → parseDocx, otherwise (.epubor unspecified)→ parseEpub. Both parsers return the sameEpubParseResultshape 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 /parseaccepts{ filename, content_base64 }(max 25MB decoded — route-mountedexpress.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-seasonaccepts the edited chapter list + season metadata (title,premise, optionalgenre/setting), creates the season (draft, novel mode, bounded narrative, owner=caller), inserts episodes withchapter_prosepre-populated +triggered=true, and synthesizes one live take per chapter stampedplatform_version='ported_v1'(same sentinel notebooks.ts:2c uses post-fab5f8e5). Parserepub-parser.ts— pure-Node, no new dependency. Uses already-presentadm-zipto unzip, readsMETA-INF/container.xmlto 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.sqladdspronunciation_lexiconskeyed by(season_id, term)withipa text,respell text,notes text,case_sensitive boolean, and a CHECK constraint requiring at least one ofipa/respell(empty entries can't accumulate). Super-admin RLS matches the bible / takes / critic pattern. Servicepronunciation-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).applyLexiconSyncextracts 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 beforettsSegmentfires at every TTS callsite in the season audio pipeline:season-novel-chapter-audio.ts(chapter audio render),season-audio.tsrenderSeasonAudio(segment render), ANDrenderIntentPulse(multi-voice Breakout-framework chorale — each voice fragment passes throughapplyLexiconToTextbefore 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 viarequireSuperAdmin. 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-containfor iOS Safari). Opened via🗣 Pronunciationbutton inSuperAdminPaneltools 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. Migration119_chapter_take_critiques.sqladdsepisode_take_critiques(findings as jsonb withparagraph_indexinstead ofsegment_position) +episode_critique_decisions(compositefinding_id = "{critique_id}:{idx}"with actionapply | ignore | override, tracksapplied_at+applied_to_take_idfor the iterate-with-notes loop). Reuses the same critic registry fromscript-critics.ts— no new critics needed; the existing tension/dialogue/pacing critics work on chapter prose unchanged. Serviceepisode-take-critiques.ts—runChapterCritique(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:generateChapterTakeacceptsapply_critiques_from_take_id— when set, builds the block from that take's pending decisions, threads it throughgenerateChapterRaw → callChapterPromptGPT(which now accepts an optionalcritiqueBlockslice 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 inChapterTakesModal— 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.tsat the episode level. Migration118_story_episode_takes.sqladdsstory_episode_takes(episode_id + season_id FKs, denormalizedepisode_number,prosetext instead of jsonb segments,prompt_snapshot,bible_version_idsoft-FK toseason_story_bible_versionsfor take-level provenance) andstory_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 alegacy_v1live take for every existing novel-mode chapter that haschapter_prosepopulated, soactive_take_idpoints at a real row everywhere — the takes UI shows "1 take (legacy)" rather than "0 takes" for chapters generated pre-feature. Migration also tightensepisode_take_bible_deltas.take_idFK tostory_episode_takes(id)(was nullable in PR2; new PR4 deltas saved by chapter takes populate it). Serviceepisode-takes.ts—generateChapterTake(episodeId, opts)callsgenerateChapterRaw(extracted fromseason-novel-chapter.ts) which does NOT persist, then inserts asstatus='draft'and queues any bible_delta withtake_idset.promoteChapterTake(takeId)atomically: archives the prior live take for the episode, flips this take live, writesproseintochapter_prose, setsactive_take_id, and rebuilds segments via a local cache-preserving copy ofrebuildSegmentsFromCache(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: newChapterTakesModal.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 intotakes-shared.tsx(STATUS_TONE,formatCost,formatDuration,FrameworkOption,ModelOption) —TakesModalwas updated to import from there. New📚 Takesbutton per triggered chapter inEpisodesAdminModal(novel mode only) shows count badge of non-rejected takes. PR5 will add the chapter-level critique loop. The legacygenerateChapterpath (used by the existing✍ Generate prosebutton) still writes directly tochapter_prosefor 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(instory-bible.ts) reads every triggered chapter'schapter_prose, sends the full text to GPT (lower temp 0.4 — extraction not invention), and produces a fresh bible withbootstrapped_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.rebuildSummaryFromProseregenerates justsummary_so_farfrom 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 planAND📚 Backfill from proseside-by-side; the Current tab's bottom toolbar exposes↻ Re-bootstrap,📚 Backfill from prose, and↻ Rebuild summaryfor ongoing maintenance. Bothbackfillandrebuild-summarysnapshot the current bible toseason_story_bible_versionsbefore writing, so the history timeline preserves every state. -
Embedded-JSON-envelope fix —
stripJsonEnvelopeFromProseextension +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\nescape 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 instripJsonEnvelopeFromProseonly checkedprose.startsWith('{')orprose.startsWith('```json'), so the leading-prose buffer hid the embedded envelope from every safety net (helper + scanner + migration 281). Code fix inseason-novel-script.ts: addedfindEmbeddedProseEnvelope(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}, thenJSON.parses the substring. When a valid{ prose: string }is found mid-stream, splice: keep prose up to{, append the parsed prose value (with\n\ncorrectly unescaped by JSON.parse), drop the JSON syntax + anything after}. Multiple embedded envelopes in one row handled via a loop. Rescues nestedbible_deltaas 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.segmentsJSONB) with a plpgsql port of the same brace-balance + parse logic. Idempotent — rows without an embedded envelope are untouched. Scanner coverage:prose-leak-scanner.tscallsstripJsonEnvelopeFromProsedirectly, 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 — existingseason_audio_segmentsaudio 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 adminiinfo badge) that one-clicksPOST /api/story-seasons/:id/episodes/:n/repair-prose(story-seasons.ts). The route runs the sameanalyzeProsehelper (now exported fromprose-leak-scanner.ts) againststory_episodes.chapter_prose+ everystory_episode_takes.prosefor that episode (so a future promote of an older take doesn't reintroduce the leak), then fire-and-forget queues arenderChapterAudio(force=true)under the existingstage='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 tomigration 278(which handled SUFFIX leakage ofbible_deltaJSON 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 wrapsresponse_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 existingstripBibleDeltaJsonFromProsesafety 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 existingseason_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.sqladdsepisode_take_bible_deltas(nullabletake_idfor now — chapter takes land in PR4; requiredepisode_idFK tostory_episodes). Status lifecycle:pending → applied | rejected | superseded. Servicestory-bible-deltas.ts:saveProposedDelta,listDeltasForSeason,approveDelta(applies on top of current bible viaapplyDeltain 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 inseason-novel-chapter.tscallChapterPromptGPTnow switches toresponse_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 inStoryBibleModalwith 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.sqladdsseason_story_bibles(one canonical row per season — characters/themes/settings/motifs/planted_threads/voice_rules/tone_rules/do_not_use as JSONB, plussynopsisand rollingsummary_so_far) andseason_story_bible_versions(append-only history withcommit_messageper version). RLS is super-admin-only — the bible includessecret_knowledgeper character + planted thread setups, both spoiler material. Servicestory-bible.ts:getCurrentBible,getBibleVersion,listBibleVersions,bootstrapBibleFromPlan(one GPT call againstseason.premise + setting + episode plan, idempotent unlessforce),commitBible(snapshots prior version, writes new contents to canonical row, bumpsversionint),buildBiblePromptBlock(token-budgeted to ~1500 tokens; trims by relevance — drops motifs not yet planted by the current chapter, thenstatus='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:generateNovelSeasonPlaninstory-seasons.tsauto-bootstraps the bible after season insert (best-effort; failure doesn't block season creation). The chapter prompt in bothseason-novel-chapter.tsandseason-novel-script.tsinjects 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📖 Biblebutton inSuperAdminPaneltools 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 reuse —
renderChapterAudioandrenderSeasonAudionow skip ElevenLabs entirely for any segment whoseaudio_urlis already set; they download the cached MP3 from storage instead. The cache key is the row itself:season_audio_segments.audio_urlis only populated on a successful render of the currenttext_content+voice_id+voice_direction, andrebuildSegmentsFromCache(inseason-novel-chapter.ts) now snapshots the existing(episode_number, segment_type, voice_id, voice_direction, text_content) → audio_urlmap 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 reportsX TTSed · Y cachedso 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
draftandpublicvisibility. Migration109_episode_visibility.sqladdsstory_episodes.visibility('draft' | 'public', default'public') and replaces the public-read RLS policy withtriggered = true AND visibility = 'public'. Service helpers inapps/backend/src/services/story-seasons.ts:untriggerEpisode()flipstriggered=falseand recomputescurrent_episodeto 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-episodenow accepts{ episode_number }to target a specific episode (fallback: next episode),POST /:id/untrigger-episodebody{ episode_number },PATCH /:id/current-episodebody{ episode_number },PATCH /episodes/:episodeIdbody{ visibility: 'draft' | 'public' }. TheGET /:idroute best-effort-detects super-admin auth and returns drafts only to admins; non-admins see public episodes only. The/timeline-chartpublic endpoint also filters byvisibility = 'public'. UI:EpisodesAdminModal.tsx— per-episode triggered/active/visibility controls, opened from a📋 Episodesbutton inSuperAdminPanelthat replaced the old▶ Next Episodebutton (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(migration108_season_script_takes.sql) as a draft row; an admin diffs takes side-by-side and promotes one tolive. Promotion copies the take's segments intoseason_audio_segmentsso audio/music/video pick up the change on the next render. Serviceapps/backend/src/services/season-takes.ts—generateTake()callsgenerateSeasonScript(seasonId, { persist: false })so the live segments stay untouched until promotion;promoteTake()archives the previous live take, flips status, rewritesseason_audio_segments, and updatesstory_seasons.active_take_id. Frameworks live inapps/backend/src/services/story-frameworks.ts— currentlythree_act,kishotenketsu,vignette_mosaic,in_medias_res. Each framework definesintensity_curve,vignette_cadence,narrator_role,entry_style, and aprompt_directivethat gets concatenated into the script GPT prompt bybuildFrameworkPromptBlock(). LLM provider shim inapps/backend/src/services/llm.ts— narrowcomplete()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 onmodel_key; calculates per-call cost in cents using public list prices (refresh manually); auto-logs every call tollm_call_logfor/dashboard/admin/llm-spend. Tool calling:LLMCompleteOpts.tools+tool_choicepass through to OpenAI;LLMCompleteResult.tool_calls+finish_reasonsurface back.LLMMessagesupports a'tool'role + optionaltool_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 whentoolsis set (their schema differs in non-trivial ways —input_schemavsparameters,tool_usevstool_callsblocks — 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-keyavailableflag 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/:takeIdwithaction: '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🎭 Takesbutton next to👁 PreviewinSuperAdminPanel. The existinggenerateSeasonScript()signature gained anopts: { 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 existingGenerate Scriptbutton 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 Shorts —
migration 290addstrailer_vertical_video_url+trailer_vertical_rendered_at+trailer_vertical_duration_secondsonstory_seasons. Serviceseason-trailer-vertical.tswraps the existing horizontal trailer in 1080×1920 branded chrome via ffmpegfilter_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), andgraphene.fmhandle. 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-passfilter_complexwith 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 viaseason-video.ts:getFontPath(). Drawtext sanitization stripped same as horizontal (backslash, colon, quotes, brackets). Output is+faststart-muxed so social-platform uploaders can stream-process. RoutePOST /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 ofStageStrip.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 ffmpegsubtitlesfilter withforce_styleoverrides 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: missingOPENAI_API_KEYor 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 supportsverbose_jsonwith 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. RouteGET /api/admin/missing-vertical-trailersreturns the count + the next batch's titles;POST /api/admin/bulk-render-vertical-trailersrenders 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 Nwhere 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 291addssocial_caption_draft+social_caption_drafted_atonstory_seasons. Servicesocial-caption.tscallsgpt-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 ofrenderVerticalTrailerso 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: missingOPENAI_API_KEYor LLM failure logs + returns null so the trailer-render success path doesn't fail. UI: the bulk banner on/dashboard/admin/seasonsnow renders a per-show caption card under the success message with a📋 Copybutton per row (1.8s✓ Copiedflash 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(addstrailer_audio_url,trailer_video_url,trailer_rendered_at,trailer_beats jsonb,trailer_focus_persona_id,trailer_duration_secondscolumns tostory_seasons). Serviceapps/backend/src/services/season-trailer.ts—selectTrailerBeats()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/grapheneposter grid — replaces the previous inline<Link>in graphene/page.tsx). The'trailer'stage is wired intoSTAGE_INPUTS(audio + music + visuals) soInlineRunsFeedshows version provenance likeTrailer v2 (Audio v3; Music v2; Visuals v1). PostHog events for measuring whether the trailer surface earns its keep:graphene_poster_hover(withseason_id,had_trailer,dwell_ms— only fires for hovers ≥400ms so the funnel isn't noisy),graphene_poster_clicked(withhad_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(withwatched_ms). All wrapped viatrackProductEvent()inapps/frontend/src/lib/analytics.ts.
Paid-acquisition conversion + measurement (June 2026)
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 hero —
ColdAdListenHero.tsxrenders a prominent one-tap trailer player (reusingTrailerHero.tsx) as the FIRST thing cold-ad visitors see — no navigation, sign-up, or paywall. Gated onisPaidAdVisitor()inanalytics.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). Aplacementprop ('season_page' | 'homepage') tags events + drives the heading level (h1 only where it's the page's main heading) and the surface tag. Measured bycold_ad_listen_hero_shown(impression, carriesplacement) →season_trailer_playwithsurface= 'cold_ad_hero' / 'cold_ad_hero_home' (TrailerHero gained asurfaceprop). 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_sourceand signups couldn't attribute back to a campaign.analytics.tscaptureFirstTouchAttribution()stashes utm_*/fbclid/gclid/referrer in localStorage on first landing (first-write-wins), andidentifyUser()replays them as$set_onceperson props (first_touch_*) at signup — works despite identified_only, no extra anonymous-profile billing. Synthesizesutm_source=fbfrom a bare fbclid (organic FB shares) andutm_source=googlefrom a bare gclid (YouTube Studio promotions, which strip utm). Fired fromPostHogProvider.tsx; the OAuth callback path routes throughidentifyUsertoo. - Reverse proxy —
next.config.jsrewrites/ingest/*→ PostHog US cloud (+skipTrailingSlashRedirect), andposthog.initusesapi_host: '/ingest'so ad blockers (which blacklist *.posthog.com) can't drop 10-25% of events. - Scoped session replay —
disable_session_recording: trueat init, thenstartSessionRecording()only forshouldRecordSession()(=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).getSeasonEngagementStatsreturns: distinct-identity counts for views/reads/listens/trailer-plays (all-time + trailing-7-day),follows/follows_7d(rows inseason_follows, migration 218), and audio-depth aggregates offchapter_listen_sessions(migration 121) —chapter_listens(total identity×chapter sessions),chapter_completions(+_7d) (thecompleted≥90% flag),distinct_chapters_listened(distinctepisode_ids reached), andlisten_minutes(Σmax_progress_seconds/60). The new fields are optional on the frontendEngagementCountsso stale cached payloads degrade gracefully. Both/admin/engagement-stats+/my-engagement-statsroutes pass the service output through unchanged — no route edits needed to add a stat. Trailer plays dual-write atrailer_playrow toseason_engagement_events(TrailerHero →season-engagement-beacon.ts→/track-engagement);migration 353widens thekindCHECK to allow it. Landmine:recordEngagementEventhas its ownkindguard that must also be widened for any new kind (was missed fortrailer_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_untilnull/future — never gives away paywalled audio). Events:cold_ad_listen_hero_shown(carriessource: 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=1through 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 hourlychapter_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 usegrapheneOrigin()). - View-as "Ad visitor" preview modes (
view-as.ts+ViewAsPicker.tsx) — the eyeball menu gains "Ad visitor — Meta/YouTube" (only on season +/grapheneroutes) so a super-admin previews the cold-ad hero without clicking a real ad.isAdViewAs(mode)flips the cold-hero gate reactively; deliberately separate fromisPaidAdVisitor()/shouldRecordSessionso previews aren't recorded or counted as real ad traffic. - Viewport-gated impression events (
useImpressionRef.ts) —season_trailer_view(TrailerHero.tsx) andcold_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); loggedmarketing_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 byutm_source/utm_campaign,utm_medium in ('paid','cpc'), fail-soft like season-traffic-by-source) behindGET /api/admin/ad-efficiency?since=48h(routes/admin-ad-efficiency.ts,sinceaccepts<n>h/<n>d/YYYY-MM-DD). The UI twin of the CLIscripts/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 ageneratingrow;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:EntrySongPanelon 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 toMAX_PER_RUN(default 25) per tick. Hourly cronrunJournalWeeklySongLoopinindex.tswith heartbeatjournal_weekly_song_tick,CRON_REGISTRY['journal_weekly_song']+isCronEnabledguard,CRON_LABELS. Two master gates (Eleven Music cost burner): the cron toggle AND envJOURNAL_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 424addsis_public(default true),cover_url,title): songs are public-by-default (lyrics generated name-free), surfaced anonymously (no author/source entry). FeedGET /api/journal-songs/public?limit=(default 24, cap 100; soft-auth stamps per-rowcan_hidefor the owner/super_admin). Single songGET /api/journal-songs/public/:id(share page + OG unfurl; 404 unless public+ready). UnpublishPATCH /api/journal-songs/:id/public { is_public }(owner or super_admin). Surfaces: the graphene home rowGraphenePublicSongsRow(now with a per-card Share (SongShareButton, native share sheet → clipboard) + an All songs → link); the all-songs browser/player at/songs(page+ metadatalayout) — 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+ clientSongShareView: hero player, lyrics, "Write yours →" CTA). Both pages ride the graphene.fm passthrough routing (live atgraphene.fm/songs); share links use the graphene.fm domain. Purpose-built 1200×630 OG cards (edge@vercel/og):(cover art + title) for a single song, and/api/og/song/[id](collection title + a strip of real cover thumbnails from the feed) for the browser — both wired into the pages'/api/og/songsgenerateMetadata/layout assummary_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 throughupdateUniverse'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'suniverse_canonpayload now carriesportrait_urlper character (merged fromgetUniverseCharacterPortraitsin theGET /api/story-seasons/:idhandler), so the cards render art on first paint; clicking Generate starts a 6s poll ofGET /universes/:key/publicthat shimmers each empty card (animate-pulse) and swaps in finished portraits live — no reload. - Style-reference IMAGE anchor —
migration 357addsstory_universes.style_reference_image_url(distinct from the displaycover_image_urland from a regenerated seasonposter_url). When set, both portrait pipelines go image-to-image via gpt-image-1images.edit(the shareddalleEditToBuffer+fetchImageBufferfrom season-media), anchored to that image — the only way to actually match a Midjourney aesthetic, which thevisual_styletext can only approximate.getEffectiveStyleForSeason()returnsreference_image_url(always the universe's — a per-book text override carries no image). Critical:images.editotherwise 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 (reusesCoverImageField,universe-coversbucket). - Book cast + poster adopt it —
season-media.tsprepends 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 characters —
migration 356adds theuniverse_character_portraitsside table (keyed byuniverse_key+character_key=BibleCharacter.key,sourceauto|upload).universe-character-portraits.tsrenders 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/publicpayload attachesportrait_urlper character; the gallery on/universes/[key]shows them via theCharacterPortraitclient 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.tsx→POST /:id/cast/:castId/upload-portrait). Both sharp-normalize to a square PNG, cache-bust the URL, and marksource='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/newaccepts URL params for prompt + title + genre + voice (#384) so any shared link can pre-fill the upload form. The/examplescurated prompt library (#389) atapps/frontend/src/app/examples/page.tsxis the canonical entry to this — every card is a one-click "→ open in Studio" link with the params attached./studio/welcomemodernized (#398) — prompt-first paths, examples link surfaced, admin URLs removed from creator-facing onboarding./studio/dashboardoutbound 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. ShareAudiobookCardwith 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. MakerShareCardon/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.
- First-render celebrate moment on
- 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. Theembed_loaded+embed_audio_playedPostHog 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.
- oEmbed support + iframe-friendly embed page at
- 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. WriterArrivalCallouton 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]/readafter 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'suniversekey, 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 stamputm_source=more-in-universe. (Required addinguniverse/total_episodes/source_panel_image_urlto thePublicSeasoninterface ingraphene-public-seasons.ts— the/api/story-seasons/publicendpoint already returned them.)
- Inline "make your own" callout for share-arrival recipients on
- Listener retention surfaces —
- Browse-side recruitment surfaces —
- Defensive infra —
- HTML-entity decoder on email subjects in
safeResendSend(#406) — a small but important fix: HTML-encoded subject lines (e.g.&,') were reaching inboxes literally. Now stripped at the boundary.
- HTML-entity decoder on email subjects in
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 filter —
is_published_to_graphene = trueANDtrailer_vertical_video_url IS NOT NULLANDsocial_caption_draft IS NOT NULLAND nosocial_post_logrow for(season_id, platform)yet. Sorted bytrailer_vertical_rendered_at DESCso freshly-rendered shows surface first. Caller-side dedup againstsocial_post_log.season_idfiltered 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 inferrednext_steplabel (render_horizontal_then_vertical/render_vertical_only/draft_caption_only), and a one-tap ▶ Build short button that fires the chainedrender-short-pipelineendpoint. 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 packbutton in StageStrip's trailer stage on/seasons/[id](super-admin gated). -
Ad pack export (#434) — migration
344_season_ad_pack.sqladdsad_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. Serviceseason-ad-pack.tsdoes the ffmpeg square wrap + thumb extract + gpt-4o-mini JSON-mode caption draft (~$0.001/pack). Standalone re-render atPOST /:id/render-ad-pack. UI surfaces viaAdPackPanelinside the expandedtrailerstage 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.sqlintroduces a singletonyoutube_credentialstable for the connected channel's OAuth tokens (partial unique index enforces exactly one active row). Serviceyoutube-upload.tsmirrors thegoogle-calendar.tsOAuth shape (buildAuthUrl/exchangeCodeForTokens/refreshAccessToken/getActiveCredentialswith auto-refresh /revokeToken) and hand-rolls themultipart/relatedupload body (nogoogleapispackage — saves ~7 MB bundle). Routes atroutes/youtube.ts:GET /oauth/start(super-admin),GET /oauth/callback(public, state-validated viaauth.admin.getUserById+isSuperAdmin),GET /status,POST /disconnect,POST /upload(downloads the vertical MP4, uploads, auto-logs tosocial_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 YouTubebutton next to the existing↗ Open YouTubelink. Quota: YouTube's default 10k-unit/day ceiling allows ~6 uploads/day (1600 units pervideos.insert); request a quota increase via Google Cloud Console once volume requires it. Description gets#Shortsauto-appended for the Shorts classifier. -
Cmd-K palette entry —
▶ Social posting consolewith synonymssocial / 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_kindso 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. BackendPOST /api/youtube/uploadacceptsasset?: 'trailer' | 'maxell_ad'(PR #492) + optionalvideo_urloverride (PR #502) so per-render uploads from the maxell editor work too. -
Build Short auto-heals missing audio (PR #495). The
render-short-pipelineroute adds a step 0 pre-flight that checksseason_audio_segments; when segments don't exist or allaudio_urls are null AND no horizontal trailer is cached, the chain runsrenderSeasonAudiofirst, then proceeds. Detail string starts withAudio (auto-healed): X/Y TTSedso 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 globalapp.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 oncereq.bodyis populated, so the per-path parser wins for matching paths and the global one harmlessly skips for them. Pattern inapps/backend/src/index.tslines ~218-230. -
YouTube Shorts rail — PARKED (PRs #504 → #505 → #506). Backend endpoint
GET /api/graphene/youtube-shortsreadssocial_post_logforplatform='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. Componentapps/frontend/src/components/graphene/YouTubeShortsRail.tsxrenders 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 eventgraphene_youtube_short_clickedstays 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.ts—renderMaxellAd(seasonId, opts)is the entrypoint. Reads the resolved layout, picks a reel (random or pinned vialayout.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 ffmpegfilter_complexgraph. Uploads the final MP4 toadpack/<seasonId>/maxell-ad.mp4and stampsmaxell_ad_video_url+maxell_ad_rendered_atonstory_seasons, plus prepends aMaxellAdRenderEntrytomaxell_ad_rendersjsonb (capped at 10). -
Migrations:
346_maxell_ad.sql—story_seasons.maxell_ad_video_url+maxell_ad_rendered_at347_maxell_ad_layout.sql—story_seasons.maxell_ad_layout jsonb(free-form so new editor knobs don't need migrations)348_maxell_ad_renders.sql—story_seasons.maxell_ad_renders jsonbarray 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-adbody{ highlight_id?, pullquote_ref? }— fire-and-forget, 202 with tracker event under stage'trailer'. Voice-required (PR #476 pre-gates in UI). Throws cleanly ifnarrator_voice_idis null.GET /:id/maxell-ad-layout— returns{ layout, defaults, raw_override, season: { title, poster_url, premise, narrator_voice_id }, renders }PATCH /:id/maxell-ad-layoutbody{ layout }or{ reset: true }— round-trips throughresolveMaxellLayoutto drop unknown keys + fill defaultsGET /:id/highlights— flat list of saved highlights for the karaoke source pickerGET /:id/pullquotes— LLM-picked magazine sentences from each episode'spullquotesjsonb, flattened with syntheticref=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_idorlayout.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);
🎲 Randomfirst or pin a specific one. Persists tolayout.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 CTA —
enable='gte(t,start)'drawtext that flashes a CTA in the final N seconds. DefaultListen and Read free at graphene.fm. - Custom elements — operator-added array of text labels + horizontal
drawboxlines. Each one a selectable canvas element.
- Source — picker grouped Premise (default) / Highlights / Pullquotes. Selection persists via
-
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-linediagnosticbreadcrumb (song: ... → mixed | no music, font embed size, Whisper word counts) so post-mortems don't require Railway log digs (PR #471). -
Font handling —
MAXELL_FONT_CATALOGships 5 catalog fonts from jsdelivr CDNs.ensureMaxellFontsDir()downloads them to a stable tmp dir on first render. Title/premise/tagline drawtext layers usefontfile=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 realignment —
realignWordsToOriginal(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 musicprint to Railway logs AND the latest of these (diagnostic) is stamped ontoMaxellAdRenderEntry.diagnosticand surfaced under each card on the editor's Render history pane. -
StageStrip integration —
StageStrip.tsx'trailer'stage exposes:- 💭 Maxell ad (pilot) — opens
MaxellAdSourcePickermodal 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.
- 💭 Maxell ad (pilot) — opens
-
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=1post-scale. H.264 output is tagged with-colorspace bt709 -color_primaries bt709 -color_trc bt709 -color_range tvso 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=stereoon BOTH inputs before amix;volume=2.0after amix undoes amix's built-in 1/N normalization at 2 inputs.
- 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
-
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 rows —
POST /api/admin/veneer/tracks/:id/replace-audio(multipartfile) →replaceTrackAudio()inveneer-tracks.tsoverwrites the stored MP3 at the same storage path (loudnorm re-master, re-probe duration, updatefile_size_bytes, cache-bustmp3_urlwith?v=); title/cover/status/source untouched. Surfaced as a per-row "⤒ Replace audio" button (amber when the row is a stub) on theveneer-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 fix —
ActivePipelinesWidget.tsxpreviously 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 viagetSession()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 inapps/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 byreel_idxkeep working. Imported reels live in Supabase Storage underseason-assets/maxell-reels/<uuid>.mp4. Per-asset render flow inseason-maxell-ad.tscallsresolveRenderReel(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.visibleand the renderer skips the rounded-rect overlay when hidden. Title/karaoke/wordmark/outro CTA still draw at their absolute positions. Same pattern thetrailing_circleschip 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_refget 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/uploadaccepts avideo_urloverride that bypasses the season-column lookup. Posts log tosocial_post_logwithasset_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 → passesseasonIdto 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
SuperAdminPanelwith the 9-stageStageStrip+ 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.
- 🎬 Overview — inline
-
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 returnReadonlyURLSearchParams | 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.tsxon the public/seasons/[id]page (owner/admin gated, always shown). Was the in-page#publish-adminanchor 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.
- ⚙ Manage chip on each row of
-
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.ts—generateReconnectionNarrative()reuses Odessa'sgatherUserContext()(journals +family_members+ mood) but a distinctLIGHTHOUSE_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 meteredcomplete()helper (feature_key: 'lighthouse.narrative', temp 0.7). Returns{ title, to_the_parent, chapters[], letter_to_child, bridge_forward }— theletter_to_childis generated to be safe for a grown child to receive cold, but is NOT delivered in Phase 1. - Endpoints:
apps/backend/src/routes/lighthouse.tsPOST /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 anotebook—notebook_type='lighthouse', narrative inlighthouse_configmigration 449— so the parent can revisit + print). - Surface (tool):
apps/frontend/src/app/dashboard/lighthouse/page.tsx— super-admin gated for now (admin-onlyFeatureMenuentry); 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(+ metadatalayout+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 tolighthouse_interest(migration 450) via publicPOST /api/lighthouse/interest. On submit it fires a best-effort Slack nudge (opt-in envLIGHTHOUSE_NOTIFY_SLACK= a#channel/C…or an email to DM; no-op if unset — never blocks the signup). Super-admin review UI at(reads/dashboard/admin/lighthouse-interestGET /api/lighthouse/admin/interest), linked from the adminFeatureMenu. 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:
fetchNotebookForPrintinprint-export.tsbranches onnotebook_type='lighthouse'and builds chapters fromlighthouse_configvia the pure, golden-testedlighthouseNarrativeToPrintChaptersinnotebook-prose.ts(chapters → letter → bridge; the privateto_the_parentmirror 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.ts—gatherUserContext()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 aStorySeedwith title, theme, genre, art style, setting, characters (withinspired_byhints), 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 agraphic_novelnotebook 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 (
CharacterStatsinterface in odessa.ts):computeCharacterStats()queries 14-day window of satisfaction_quick_values, user_tasks, journal_entries, family_routine_checkins. Stats are injected into thegenerateNextEpisode()GPT prompt so character behavior mirrors real-life growth. Stats persist onStoryState.character_statsbetween 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.LiveStatsPreviewcomponent 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 exportmetadata). OG image is the promo card atpublic/promo/odessa-graphene-audiobook.png;summary_large_imageTwitter card. The card + its sibling format/copy variants are generated fromapps/frontend/public/promo/build-promo.mjs(copy deck × format geometry → HTML) and rendered to 2× PNG viarender-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-episodereads journal delta since last episode, computes character stats, generates 2-3 scenes with illustrated panels, persists updated StoryState.POST /api/odessa/resolve-branchrecords 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-modestory_seasonsrow + onestory_episodesper notebook entry withchapter_prosepopulated from panel narration + dialogue (image_description skipped — visual brief, not audio-relevant). Body:{ narrator_voice_id?, kick_off_audio? }. After insert, callsgenerateNovelScript()which sees the cached prose and SKIPS the LLM call, composingseason_audio_segmentsdirectly from the writer's own text. Optionally fire-and-forgetsrenderSeasonAudio()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. Createsowner_user_id-stamped season atstatus='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'smetadata_visibility+content_visibilitydown 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 ofis_published_to_graphene, newest first, poster-shape fields only soPosterCardrenders 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) resolvessource_notebook: { id, name } | nullby looking upnotebooks.graphic_novel_config->>ported_season_idscoped 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_atonto 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] (
PublishStripinSeasonClient.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-audiowith{ 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'saudioProgressprop 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 haschapter_prosebut nochapter_audio_urlyet, 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.
- "Your stories" row on /graphene:
- PostHog events:
odessa_landing_view+odessa_landing_cta_clicked(top-of-funnel on the/odessalanding —paidflag + 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(withkick_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.sqlextendsnotebook_typeto include'graphic_novel', addsgraphic_novel_config JSONB(art style, characters, setting, tone, conversation history, odessa_seed). - Story generation:
apps/backend/src/routes/graphic-novel.ts—POST /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_promptcapture for visual consistency). - Auto-generation:
POST /api/odessa/generate-full-storydrives 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.ts—POST /api/import/day-one(ZIP/JSON),/journey(ZIP),/csv(auto-detected columns). Multer + adm-zip. Auto-creates notebook per import. Tracksimport_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/healthinapps/backend/src/routes/admin.ts— super-admin only, runs a real DB query (latency-measured), reads applied migrations viasupabase.schema('supabase_migrations').from('schema_migrations'), returns commit SHA + start time + Node version + env. - Local-migrations enumeration:
GET /api/admin/local-migrationsinapps/frontend/src/app/api/admin/local-migrations/route.ts— server-sidefs.readdirSyncof the repo'ssupabase/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.
- Migration:
005_add_admin_impersonations.sql - Frontend:
auth/impersonate-callback - Reference: docs/IMPERSONATION_SETUP.md
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.ts—useViewAsRole()(reactive, useSyncExternalStore + localStorage + cross-tabstorageevent sync). Four modes:admin(default, no-op),creator,viewer,anonymous. State key:hj:view-as-role-v1. CompaniongetEffectiveIdentity({ 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 onactualIsSuperAdmininNavbar.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. SaysViewing 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 viaClientOnlyComponents.tsxalongside the existingImpersonationBanner. - 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 scatteredisSuperAdmin && (...)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
apiRequestbehavior. Pages that backend-gate to specific roles (e.g./studio/*requiresis_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 viaGET /api/story-seasons/mine(auth-only, not super-admin). Empty state →/studio/new. A "Write" nav item is inStudioChromeheader + footer. - Chapter list:
/studio/write/[seasonId]—'use client'page (useParams, so no Next-15params: Promiseconcern), lists chapters and opensProseEditorModalper chapter. Refreshes has-prose flags viaonSaved. Passeschapters+onNavigateto the modal so a creator can move between chapters in-editor (‹ Chapter N of M ›, flush-saves before switching); the adminChapterTakesModalcaller omits those props so the nav doesn't render there. - List endpoint (new):
GET /api/story-seasons/:seasonId/editor-chaptersinroutes/story-seasons.ts— returns{ season: {id,title}, chapters: [{episode_id, episode_number, title, has_prose, badges}] }, wherebadgesis{ dramatic_role, spine_turn, stakes_intensity (1–10), stakes_summary }— a read-only projection of the storeddramatic_role/chapter_spine/chapter_stakesJSONB via the pureservices/chapter-badges.tsderiveChapterBadges(no compute; golden-tested inchapter-badges.test.ts). GatedrequireSuperAdmin(accepts is_creator) +assertSeasonProseAccess(own-or-super, same gate as the prose read/write endpoints). Uses:seasonId(not:id) to bypass therouter.param('id')gate — same pattern asprose-assist/style-example. - Chapter management (owner-gated, no migration): the
/studio/write/[seasonId]page can add / rename / reorder / delete chapters. All inroutes/story-seasons.ts, gatedrequireSuperAdmin+assertSeasonProseAccess:POST /:seasonId/chapters(append a blank chapter atmax(episode_number)+1, settriggered:true, visibility:'public', chapter_prose:''to match the manuscript-import convention),PATCH /episodes/:episodeId/chapter-meta(renametitle),DELETE /episodes/:episodeId/chapter(delete + renumber the tail down one, ascending so it never tripsunique(season_id, episode_number); FK children are allon delete cascade/set nullso takes/prose_versions/audio_takes clean up on their own; best-effortrebuildSegmentsFromCacheLocal),PATCH /:seasonId/chapters/order(body{ ordered_episode_ids }= a permutation of the season's chapters; two-pass renumber — park at100000+i, then1..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_idmigration 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 sendsepisodeIdon thejq:open-canonevent →Chatbotstorescanon_episode_idon the conversation (chat.tscreate).canon-mode.tsbuildSeasonCanoninjects the chapter prose (capped 8k, placed high so it survives the 14k canon cap; validatedep.season_id === seasonIdso a stale/mismatched id can't leak another story's prose), and a newcanAccessCanonScope(own-or-super for season scope, super-only for universe) gates canon at all three seams — conversation create,generateChatResponse, andexecuteCanonTool. Universe canon stays super-admin-only. - Composer environment (A1a-env, PR #979):
ProseEditorModalhas 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 inlocalStorage(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.sqladdsepisode_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.ts—createTipCheckout(opts)inserts apendingrow, builds a Stripe Checkout session inpaymentmode (one-time, not subscription) using inlineprice_data.unit_amountso we don't have to pre-create Stripe prices for $1/$5/$10. Metadata carrieskind: 'episode_tip', tip_id, season_id, episode_number, amount_cents — enough for the webhook to attribute without a separate query.recordTipPayment(sessionId, paymentIntentId, status)flipspending→paidoncheckout.session.completed. Idempotent (skips already-final rows). - Route:
POST /api/payment/tip-episode(auth optional viamaybeAuthenticateUser) — 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-Nand/seasons/[id]?tip_canceled=1#chapter-N. - Webhook: extended
checkout.session.completedhandler inroutes/payment.ts— branches onsession.mode === 'payment' && metadata.kind === 'episode_tip'BEFORE the existing subscription-handling code. Firesepisode_tip_paidPostHog server event (distinct from subscription events so funnel reports don't conflate the two flows). - Frontend:
components/seasons/TipChapterModal.tsxrenders three preset amount cards + an optional one-line note, redirects to Stripe Checkout on submit. No custom-amount in v1 (keeps the picker fast).ChapterPlaylistPlayergets a💸 Tippill 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 viahistory.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 intoReadClient.tsxonEndedhandler): 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 (promptedEpisodesRefSet); 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" opensCoinTipModal(no checkout round-trip — best UX, requires drift_enabled + signed-in); "Tip the team" opensTipChapterModalStripe flow. Gated bytippingEnabledsite 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 209createsgraphene_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.ts—createGiftCheckout()mints a code + insertspendingrow + 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 + firessendRecipientEmail()(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), stampsrecipient_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.tsisGrapheneSubscriber()is extended to OR inhasActiveGiftEntitlement()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.tscheckout.session.completedhandler branches onsession.mode === 'payment' && metadata.kind === 'graphene_plus_gift'BEFORE the subscription branch. CallsrecordGiftPayment+ firesgraphene_plus_gift_paidPostHog server event (distinct fromgraphene_plus_subscription_activeso the funnel separates gift revenue from sub revenue). - Routes:
POST /api/payment/gift/checkout(auth optional viamaybeAuthenticateUser— anonymous buyers welcome),GET /api/payment/gift/:code(public lookup),POST /api/payment/gift/:code/redeem(auth required). - Frontend:
/gift-graphene-plusis 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 withcontinueparam), paid-and-signed-in (big "Redeem 12 months" button → 365-day window starts now). - Email:
graphene-plus-gifts.tssends a styled HTML + text email via Resend. Subject:sendRecipientEmail()<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-giftslists 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). BackendlistSentGifts({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 forstatus='paid'gifts. Routes:GET /api/payment/gifts/sent(auth) +POST /api/payment/gifts/:id/resend(auth + ownership check). - Recipient subscription page:
/dashboard/subscriptionrenders a distinct purple "🎁 Gift active" card whenentitlement_source === 'gift'instead of the standard "Manage billing" card. Shows purchaser email + expiration + a "Subscribe to extend" CTA. BackendgetActiveGiftEntitlement(userId)returns the latest-expiring redeemed gift (handles stacked-gifts edge case); theGET /graphene-plus/statusendpoint surfacesentitlement_source('subscription' | 'gift' | null) +gift: { expires_at, redeemed_at, purchaser_email }. - Pre-expiry reminders:
migration 210addsreminder_30d_sent_at,reminder_7d_sent_at,reminder_1d_sent_attographene_plus_gifts+ partial unique indexes for the cron's range scan.sendGiftPreExpiryReminders()(ingraphene-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 inapps/backend/src/index.tsat 1h cadence (heartbeat namegift_pre_expiry_reminder_tick); per-gift failures land inerrors[]so a single bounce doesn't block the rest of the queue, and the heartbeat status flips topartialwhen 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 208updates thestory_seasons.modeCHECK to allow'timelines', addspath_key texttostory_episodeswith 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 carriesbeat_type(inciting/rising/midpoint/climax/denouement/transition),stake_level(1-10),tension_type(moral/physical/emotional/existential), and an optionaldream_of_path+dream_seedpayload. 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 inX-Anon-Idheader; signed-in readers get cross-device state via user_id. Partial unique indexes per identity column.
-
Service:
services/timeline-seasons.ts—generateTimelinePlan()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 adream_of_pathtag — 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,markPathCompletedcover 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-Idheader):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 } }.getTimelineChoiceStatsfetchesreader_timeline_state.choice_1/choice_2and calls the pureaggregateChoiceStats(rows)(golden-testedtimeline-choice-stats.test.ts) — counts only, no per-reader data, so it's safe to expose (vs. the super-admin/analyticsfunnel). choice_2 is split BY PARENT branch (thechoice_1the reader was on), which/analyticsdoesn't do.TimelineClientfetches it (refetched when the reader picks, so their own vote counts) and renders aChoiceResultbar pair AFTER each committed choice (never before — no biasing), the reader's own pick highlighted "Your pick";total<=1shows "you're the first". Modeled on thecafe_battle_pickscrowd-split pattern; the same endpoint feeds the VR branching room (Phase 3, GRAPHENE_VR_STORY_PLAYER.md).
- Admin (super-admin):
-
Reader UI:
/seasons/[id]/timeline— dedicated reader page (separate from/readbecause the path-aware, choice-gated flow is fundamentally different). On mount: mints/readstimelines_anon_id_v1from 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 bycompleted_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 backendgenerateSeasonPlanshort-circuits to "create the season row only", deferring branching plan generation to a follow-up. Per-season action buttons render only onmode='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 juststory_episodesrows. -
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⚖️ Paritybutton) renders 4 stake bars + climax-stake values + tension-type badges, with🔁 Regen weak leavesenabled 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.ts—renderTimelineChapterAudio({seasonId, pathKey, episodeNumber})is the path-aware analog of novel-mode'srenderChapterAudio. Reads chapter prose (with path_key match — Ch5 exists on AA/AB/BA/BB simultaneously), chunks viachunkProseIntoSegments, TTSes each chunk with the season's narrator voice (falls back toDEFAULT_NARRATOR_VOICE), stitches with ffmpeg, uploads toaudio/{seasonId}/timelines/{path_key}/ep-N.mp3(path_key in the storage prefix so leaves don't collide), stampsstory_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 inerrors[]so a single TTS hiccup doesn't lose progress). Skips chapters that already have audio unlessforce=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 211createsseason_timeline_posters(one row per(season_id, path_key)forpath_key in {AA,AB,BA,BB}).services/timeline-posters.tsgenerateLeafPosters({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 viatensionMoodmapping). 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:TimelineClientshows the leaf poster as a hero banner above the page header once the listener is on a leaf path (length-2current_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/structureendpoint includesleaf_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.tsconvertNovelToTimelines({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 startsdraftso the conversion doesn't accidentally surface the variant on /graphene before chapters are filled in. The branching plan is generated bygenerateTimelinePlanwith a newinspiredBySeasonIdparameter — 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 fromcrypto.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.ts—ensureSubscriberRssToken(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 })inseason-distribution.tsskips thechapter_free_untilfilter, 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/subscriptionGraphene+ 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.ts — computeChapterStakesForSeason(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.ts — computeChapterSpineForSeason(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 chapterchapter_reveals— what THIS chapter is allowed to land (1–2 specific things)still_withheld— canon the writer KNOWS but the chapter must NOT touch yetknowledge_layer_generated_at— timestamp for staleness signals
Service services/knowledge-layer.ts — computeKnowledgeLayerForSeason(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 compositefinding_id → actionmap for O(1) lookup, hydrates with the registry fromscript-critics.tsso 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/adminas📊 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.ts—getBillingForUser(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_NOTESis 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 what429 tts_quota_exceededwould 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.ts—generateDiscussionScript(source, opts)(source is atopicor acontentunion; onlytopicis exposed for now) →claude-sonnet-4-6,response_format: 'json_object'. The pure, golden-testedparseDiscussionScript(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 viattsSegment(host = aVOICE_BANKvoice, guest voice) →concatMp3Buffers→uploadAudio(season-assets).POST /api/podcast-discussion/render(creator-gated; render bounded per creator by the TTS quota → 429; gated onELEVENLABS_API_KEY→ 503). Guest voice auto-wires to the caller's own Lovio clone — explicitguestVoiceId→getActiveVoice(userId)fromlovio_user_voices(record once at/dashboard/lovio/voice-setup) →PODCAST_GUEST_VOICE_IDenv → stock stand-in (Antoni); host default Matilda /PODCAST_HOST_VOICE_ID. - Content sources (shipped): both routes take a source —
topic, pastedcontent(+label, NotebookLM-style), aurl(SSRF-guarded fetch + readable-text extraction —fetchReadableText/extractReadableTextinlink-preview.ts, golden-tested),journalEntryId(the caller's own entry —ownWriting, guest reflects on their own words), orseasonId(a story's premise/setting/genre).resolveSourcein the route fetches + validates each (journal entries areuser_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 parsedscript(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)./rendernowsavePodcastDiscussion(...)after rendering (save failure logs but still returns the paid-for audio) and returns the new rowidalongside{ 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, andGET /voices(curated ~8-voice Host subset ofVOICE_BANK). Route ordering puts/mine,/for-entry/:entryId,/voicesbefore/:idso they aren't shadowed. - Host voice + style (shipped): both
/scriptand/renderaccepthostVoiceId(fromGET /voices) andstyle—DiscussionStyle = conversational | reflective | analytical | playful | debate(STYLE_DIRECTIONappends a one-sentence tone fragment to the script system prompt;isDiscussionStyle()guards; defaultconversational). - 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 souseSearchParamssits inside a<Suspense>boundary). The journal entry detail page (entries/[id]/page.tsx) shows anEntryPodcastPanel— 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 inFeatureMenu.tsx(→/dashboard/podcast#my-podcasts). - Public sharing (shipped, migration
461):podcast_discussions.is_public(default false). Owner toggles viaPATCH /api/podcast-discussion/:id/public { is_public }(setPodcastPublic, owner-scoped) from a 🌐/🔒 control in My Podcasts (+ copy-link). Public readGET /api/podcast-discussion/public/:id(maybeAuthenticateUser→getPublicPodcast; only is_public rows; owner getscan_manage) — deliberately omitssource_id/source_labelso a public page never back-links into the owner's private entry. Public share page/discussion/[id](server component, OG metadata, Next-15paramsPromise) renders title + source descriptor + audio player + HOST/YOU transcript viaDiscussionShareView, 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 intoopenGraph.images+twitter.card=summary_large_imageso 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-setupoffers Quick (one 60s reading) and Studio (upload several minutes of audio / multiple files) — both go through ElevenLabs instantvoices/add, which accepts manyfiles, so more clean audio yields a richer clone.createVoiceClone+ the/api/lovio/voice/setuproute take asamples[]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 302—tts_quota_warningstable 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.tsmaybeFireQuotaWarning(userId)runs fire-and-forget after every successfullogTtsCallinsert. 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 304—studio_credit_packsaudit table (one row per Stripe checkout or admin comp, unique idx onstripe_payment_intent_idfor webhook idempotency) +profiles.studio_extra_credits_charsint column for the running balance (decremented on TTS usage that spills past the rolling-30d base quota). - Effective quota math:
services/tts-spend.tsgetMonthlyUsageForUser()returnslimit = base_limit + extra_creditsso both the preflight quota check and the/studio/billingUI see the combined cap. Super-admins (NULL base) ignore credits. - Stripe integration (live):
services/studio-credit-packs.ts—CREDIT_PACKScatalog (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 behindSTRIPE_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/checkoutexposes the checkout; the webhook handler at/api/payment/webhookbranches onmetadata.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 existinggrantCreditPack()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 (taggedPURCHASE, with $ amount) and comps (taggedCOMP, with admin's note). Backend:GET /api/studio/admin/credit-packs(hydrated list) +POST /api/studio/admin/credit-packs/grant(callsgrantCreditPack()). Belt-and-suspenders super-admin re-check at both endpoints — the file-sharedrequireSuperAdmininstudio.tsacceptsis_creator OR super_admin, but credit grants are super-admin ONLY (otherwise creators could comp themselves). - Creator-side attribution (
/studio/billingCreditPackPanel): 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 Checkoutso 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 fromstudio_credit_packsin 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.
Studio paid-mode enforcement — trial + prepaid gate + paywall + consent (live 2026-06-12)
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 settts_monthly_char_limit = 0(no recurring free chars) + seed a one-timesource='trial'credit-pack row (STUDIO_TRIAL_CHARS, default 20k, idempotent viamaybeGrantStudioTrial). 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 ofgetMonthlyUsageForUser, 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.tspreflightStudioCreditsOrRespond):render-audio+render-all-chaptersestimate 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 inttsSegmentstays the mid-render backstop. - Paywall UX (
CreditPaywallModal.tsx+extractCreditPaywall):apiRequestnow 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/checkoutStripe flow; wired intoChapterRenderAudioButton,RenderAllChaptersModal, andSeasonClient→PublishStrip. - Recorded terms-acceptance gate (
migration 359,studio-terms.ts,StudioConsentGate.tsx):profiles.studio_terms_{accepted_at,version}capture versioned consent (bumpSTUDIO_TERMS_VERSIONto force re-accept).GET /api/studio/consent+POST /api/studio/accept-terms;requireStudioConsent(middleware/studio-consent.ts, shared) gates/api/manuscript/parse+/create-seasonand the prompt/season-create routePOST /api/story-seasons(paid mode, super-admins bypass, 403studio_terms_required).StudioConsentGatewraps both/studio/newcreation 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:
StudioChromeheader CTA: "Join the beta" → "Subscribe" (→ /graphene)/studiohero 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/dashboardonboarding tour: "Free during closed beta" → "Included with your Graphene+ subscription"/studio/welcomeeyebrow + "what's not enabled yet" billing note/studio/helphero + ContactForm copy- Grant email body (
sendStudioGrantEmailinstudio-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/termsis 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/graphenesubscription 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-linestudioPaidMode()helper reading the sameNEXT_PUBLIC_STUDIO_PAID_MODEenv var. Backend can readNEXT_PUBLIC_*env vars as regularprocess.envreads; 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 303—studio_cap_requeststable tracks(user_id, used_chars, limit_chars, pct_used, message, status, email_status, resolved_at, new_limit_chars). Status lifecycle:open→approved/declined/superseded. - Throttle: at most one
openrequest per user per 24h (route returns 429 withcode: 'cap_request_pending'so the UI can show "you already have a pending request"). - Backend:
POST /api/studio/cap-increase-requestinroutes/studio.ts— firessendStudioCapIncreaseRequestEmail(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 bumpstts_monthly_char_limit+ flips status in two writes (cap first; if profile update fails, the request staysopenso 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 sharedrequireSuperAdmininstudio.tsacceptsis_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 viaauth.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/adminas📈 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_eventstable 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
: sequential loop. QueriesimproveAllBehindChaptersstory_episode_takes WHERE status='live'for the season, filters to those whoseplatform_versionis behind current viaisVersionBehind, then iterates callingautoRefineNextChapter({ 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 thefull_pipelinestage; 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 newplatform_version_statusfield added to theGET /api/story-seasonsresponse (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
EpisodesAdminModalheader: whenbehind_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_stakesplanning 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 callsPOST /regenerate-chapter-spinethenPOST /regenerate-chapter-stakessynchronously 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.tsxbulkImproveAllBehind). On yes, fans out spine + stakes regen calls across every show-with-behind-chapters in parallel viaPromise.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:
(admin-only banner rendered onImproveAllBehindBanner/seasons/[id]) gets the same follow-up confirm as the admin modal. On yes, hitsregenerate-chapter-spinethenregenerate-chapter-stakessynchronously (each in its own try/catch — failure surfaces in the message but doesn't block the improve), then firesimprove-all-behindas 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:
generateChapterTakeinepisode-takes.tsand the season-levelgenerateTakeinseason-takes.tslazy-importgetCurrentVersion()and write the result on every insert. New takes always carry the active version. - Surface in API: GET
/api/story-seasons/:idenriches each episode withlive_take_platform_version+live_take_model_key(super-admin only). New endpointGET /api/story-seasons/:id/version-statusreturns the full registry + per-chapter status +behind_count+total_chaptersfor 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 existingautoRefineNextChapterwith a newexistingChapteropt 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 inEpisodesAdminModal.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 vialib/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.comis the canonical umbrella;write.cafeandgraphene.fmare 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 stripswww., then onwrite.cafe:/→/write-cafe,/sprints→/write-cafe/sprints. Ongraphene.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_personastable) appear as patrons in the writers'-room widget. NewgetActivePersonas()service +GET /api/write-cafe/active-personasendpoint returns personas who've published ajournal_entryin 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-countendpoint sums words acrossjournal_entries.contentin a half-open ISO window (frontend ships the local-day boundaries; server stays timezone-naive). Skips JSON-shell entries (graphic_novel/outlinepayloads). 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) aonWriteCafePill/dashboard/entries/newshows 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'swrite.cafedomain 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 toWriteCafeHeaderBrand(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_periodstable holds period dates + pool_cents (admin-entered or future Stripe-derived) + status lifecycle (draft→computed→paying→paid);creator_earnings.period_idback-points so admin can audit a row to its source period. Service:creator-subscription-pool.ts—createPeriod(declare a date range),computePeriod(readschapter_listen_sessionsfiltered to subscriber-only minutes on creator-owned shows, attributes minutes per creator, computes per-creator allocations using theirsubscription_creator_pctfromcreator_revenue_splits, writes pending earnings rows),payPeriod(firesstripe.transfers.createfor each pending row to the creator's Connect account; rows where the account isn't currently active flip toplatform_retainedfor later reconciliation),archivePeriod(escape hatch — pending rows roll back toplatform_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 reconciliation —
reconcileRetainedEarnings()batch-transfers everyplatform_retainedearning 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/earningswhen 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/growthframing 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+ cachedcharges_enabled/payouts_enabled/details_submitted/requirements),creator_revenue_splits(per-creator override of the platform-default percentages — defaults: tip 80%, subscription 70%, drift 90%), andcreator_earnings(the append-only ledger:gross_cents/creator_share_cents/platform_share_cents/stripe_charge_id/stripe_transfer_id/status∈pending/transferred/reversed/platform_retained). Service:creator-payouts.ts—ensureConnectAccount(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 setspayment_intent_data.transfer_data.destination+application_fee_amountso funds split at the moment of charge — no separate transfer call. Pre-Connect tips land asplatform_retainedrows 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 unusedcafe_random_promptsrow → 3 random active personas each write a candidate (gpt-4o, ~600–1200 words, in their voice viapersonalitySummary+ 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 nextposition↑ onshort_stories→ ElevenLabs renders single-narrator MP3 inline (chunked at sentence boundaries, ffmpeg-concatenated, uploaded toseason-assets/short-stories/<id>.mp3). Cost: ~$0.65 writing/judging + ~$3.60 narration = ~$4 per 5-story backfill. Auto-rotation:short_stories_backfill_tickcron (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-andersonlists 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 nameSJ Andersonis 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-promptwas 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 viacafe_random_prompt_views); anonymous viewers get a random ready row without dedupe. Pool topped up bycafe_random_prompt_pool_tickcron (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-cafecafe home),GET /admin/stats+POST /admin/seedfor 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-andersonshort-story backfills via theused_for_short_story_atstamp 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 byuseIsSuperAdmin()(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 viaGET /api/cafe/admin/ops-stats; refreshes every 60s. (2) The Last Five book A/B test — migration 187 adds three columns tocafe_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-andersoninstead of an Amazon URL. Click counts are bumped via fire-and-forgetPOST /api/cafe/books/:id/clickbefore navigation; super-admins see per-card click chips (emerald12 ↗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 itsINTERNAL-SJ-LAST-FIVEsentinel asin so collisions with real ASINs are impossible. - Cafe Battle pending badge (ecosystem-wide) —
CafeBattlePendingBadge.tsx, mounted globally inClientOnlyComponentsso 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 →" oncepaired_battle_idis set; links into/write-cafe/battles[/:id]. PollsGET /api/cafe/battles/queue/status(getQueueStatus) every 25s with a 1s local countdown tick; renders nothing when signed out or not queued. Portal'd todocument.body, mobile-safe (max-w-[calc(100vw-1rem)]+ responsivetext-xs sm:text-sm/truncation so it can't overhang the header) with a session-only ✕ dismiss keyed to the specificpaired_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:getQueueStatusdeletes the stale queue row (→in_queue: false) when the paired battle is gone or has moved tojudging/closed/cancelled, OR when the battle is stillpaired/activebut this user has already finalized their entry (cafe_battle_submissions.submitted_atset). 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 curatedcafe_bluesky_writerswho has a recently published book. Each active writer holds a 30-day shelf slot (slot_start_at/slot_ends_at;is_activeauto-flips false at slot end via a cheap idempotent sweep on everylistWritersForStrip()read;pinnedis immune). Each day a writer is featured: the picker prefers never-featured writers, ties broken byslot_ends_at ASCso each writer gets their feature before rotating off. The shelf shows a ✨ Today badge on the featured writer (amber-bordered card on both theBlueskyAuthorsStripand theBookCarouselWriterCard, 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@handlenear 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-writtenfeature_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 + stamplast_featured_at). Admin UI on the existing BlueskyAuthorsStrip: header gets a✨ Auto-feature todaybutton when no one has been featured today; per-card✨button triggers a single-writer feature; the add-handle form now capturesbook_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 onecafe_bluesky_eventsrow, with a snapshotted bento (so re-rolls don't change rules mid-flight), a cached LLM-drafted kickoff text, the eventualbsky_kickoff_uri+bsky_kickoff_cid(CID is required for threading), and after the activity closes a cached result draft + absky_result_uriposted asreply.root+reply.parentof 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 ascafe-bluesky-drafts.ts). Status lifecycle:scheduled → kickoff_drafted → kickoff_posted → closed(withcancelledas the escape hatch). Backend:cafe-bluesky-events.ts(createEvent/generateKickoffDraft/fireKickoff/generateResultDraft/fireResult);route fileat/api/cafe/bluesky-events(super-admin gated).bskyPost()incafe-bluesky.tsextended to returncidand accept areply: { 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
BookCarouselshelf 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 +🦋 bskybadge 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/bookspage (which renders one carousel per book category viacategoryprop, so the carousel setsincludeWriters=false). Capped at 3 so the carousel is a teaser and the dedicatedBlueskyAuthorsStrip(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 servicecafe-bluesky-writers.tspullsapp.bsky.actor.getProfile+app.bsky.feed.getAuthorFeed?limit=5&filter=posts_no_repliesfrom the public AppView (https://public.api.bsky.app/xrpc/...) — no auth, no rate-limit theatrics. Profile + latest-post fields are cached oncafe_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 handlerow + 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 viacafe-bluesky-drafts.ts(claude-sonnet-4-6, ~$0.01-0.02 per draft): (1) Invite drafts —POST /api/cafe/battles/invite-draftbody{ bento?, mentions? }. Auth'd users only. If no bento provided, rolls a fresh one viagenerateRandomBento()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.socialappended), 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: collapsibleBlueskyInvitePanelon/write-cafe/battlesunder 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 drafts —POST /api/cafe/battles/:id/result-draft(auth'd) generates + caches a Bluesky post for a closed battle oncafe_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:BattleShareToBlueskycomponent 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 insideClosedRecapon/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 anObject.keys(state).lengthcount 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 newGET /api/cafe/battles/recent-closed?limit=Nroute →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 viapanelStateFor()so the rules live in one place. (5) Auto-follow-bottom on writing panes — each WatchPane gets aref+onScrollhandler; 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 oncafe_battle_submissionsUPDATEs 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-tournamenttotal_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-matchcoin_stake_per_sideis 0 (the only money is the tournament pot).is_rankedpropagates from tournament → battle so ELO updates flow through the existing close-time path. Auto-advance via the newrunTournamentTick()(rides the existing 2-min cafe-battle cron): scans live tournaments, when ALL battles incurrent_roundare closed/cancelled it collects winners (random advance on tie), stampseliminated_at_roundon losers, creates round N+1 battles. When 1 winner remains →completeTournamentpays out the pot perprize_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): addscoin_stake_per_side+is_ranked+elo_change_*tocafe_battles; addscoin_stake+wants_rankedtocafe_battle_queue; addscafe_battle_elo(default 1200) +cafe_battle_ranked_battles/wins/losses+cafe_battle_ranked_optoutto 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 existingmutateBalancehelper, 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 ANDis_ranked=true— standard formula, K-factor 32. Bot opponents always forcecoin_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-prefsendpoint extended to returnelo,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 addscafe_battlesandcafe_battle_submissionsto thesupabase_realtimepublication; the watch page (/write-cafe/battles/watch) and the battle room (/write-cafe/battles/[id]) both subscribe topostgres_changesUPDATEs filtered bybattle_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 areload()which routes through the backend's role-awaregetBattleDetail()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 leaderboard —GET /api/cafe/battles/leaderboardreturns top 20 writers (bycafe_battle_writer_pointsdesc, wins tiebreak) and top 20 reviewers (bycafe_battle_reviewer_pointsdesc). 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 ptswhen 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 tosystem_heartbeatsrows (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 scansis_bot=truesubmissions whose battle has been paired ≥ 15 min with empty body and re-fires the persona action. Catches the case wheresetTimeout-sleeping bot writes are dropped on a process restart. Idempotent viasubmitted_atshort-circuit (~$0.10 worst-case race). (3) Writer no-show forfeiture — at 4h post-pair, the cron checks each battle: if a writer'sstarted_atmatches 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 toGET /:id/publicfor 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 → statusjudging→ 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/watchshows 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 randomis_activepersonas, fresh bento, both sides markedis_bot=trueso casual W/L stats stay clean. New croncafe_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 namedwrite.cafe(find-or-create by(user_id, name), mirroringgetOrCreateGrapheneNotebook), so their cafe work lives in their journal. write.cafe + hivejournal.com share the same Supabase auth user, so the submission'swriter_user_idowns 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 viacafe_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 ofsubmitStory()(contest) andfinalizeBattleSubmission()(battle) — best-effort + self-logging toservice_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 flippedcafe_critic_feedback_opt_in = trueon 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 oncafe_persona_noteswith 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 (separatecafe_critic_feedback_email_optinflag 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 intransitionStatus'sclosedbranch. Backend:GET /api/cafe/contests/:id/my-notesreturns the writer's notes with persona display info;POST /api/cafe/contests/notes/:noteId/mark-viewedfor the "new" badge. Frontend:MyPersonaNotesrenders inline on the writer's/write-cafe/contestsubmission view when status isclosed/paid;FeedbackPrefsToggleon/write-cafelets 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-definedCAFE_BOOK_LIBRARYconstant onto the newcafe_bookstable. 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. PublicGET /api/cafe/booksreturns 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/booksis 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:BookCarouselcomponent now fetches from/api/cafe/books(with optionalinitialBooksprop for SSR fast-path);/write-cafe/booksserver-component fetches once and renders a carousel per category. Samehivejournal-20Amazon affiliate tag as before. Click-through dashboard: the admin page surfaces total clicks across the shelf + top-5 books byclick_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 existinghivejournal-20affiliate tag (sameNEXT_PUBLIC_AMAZON_AFFILIATE_TAGenv 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'sprompt_song_urlis 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 theBentoOfTheWeekwidget 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 readsrunHealthChecks()+ 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 asops_weekly_report. Admin page at/dashboard/admin/ops-reportslists past notes (markdown-rendered), with "Generate now" + "Force regenerate" buttons. Persona pattern mirrorsSCRIPT_CRITICS— code-defined map (OPS_PERSONAS) keyed by stable string id, no FK toai_personas(no auth user needed). Default persona viaOPS_WEEKLY_REPORT_PERSONAenv var; defaults tomarlow. (2) Critic personas — added optionalpersona: { display_name, tag_line, voice_summary }toScriptCritic. 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 ontension_editor(Beatrice) anddialogue_doctor(Hugo) as initial demos; remaining critics keep the un-personified prompt. NewGET /api/admin/script-criticsendpoint 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 oncritic_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 heartbeats —system_heartbeatstable (one row per task, upsert by name) recordslast_run_at+last_status+metadataon 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 viarecordHeartbeat({ name, status, error?, expectedIntervalSeconds, metadata? }). ThewithHeartbeat()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 routeGET /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
recordServiceErrorso individual catch blocks don't have to. (1)EventTracker.failWithError(err, opts?)inseason-pipeline-events.tsdoes bothtracker.fail()(runs feed) ANDrecordServiceError()(daily triage) in one call. Defaults derive service/context from the tracker's stage, so most callers are a one-line replace oftracker.fail(msg)→tracker.failWithError(err). 20 sites swept inroutes/story-seasons.ts(audio render, script gen, trailer, podcast upload, score-music, generate-chapter, improve-all-behind, etc.). (2)recordHeartbeat({ status: 'error', ... })insystem-heartbeats.tsauto-bridges torecordServiceErrorwithservice='cron-<name>'— every cron's failure path picks this up with zero opt-in. OptionalerrorObjfield preserves the stack trace (withHeartbeat()already forwards it; lower-levelrecordHeartbeatcallers 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 acrossauto-universe-batches,short-stories, andshort-stories-audiocallrecordServiceError({ 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-sidelistGroupedErrors()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 (anycriticalrow makes the whole group critical). Per-groupResolvebutton bulk-stampsresolved_at + resolved_by_user_id;Mute (24h / 7d / forever)writes toservice_error_mutesso 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 viaClientOnlyComponentshookswindow.error+unhandledrejection; explicit catch sites can usereportClientError(). Two-layer noise filter (client-side skip + server-side reject) handles cross-originScript 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:/errorslists 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 withhttps://hivejournal-...point-seven-studio.vercel.app/seasons/...because earlier URL helpers fell back toALLOWED_ORIGINS(a CORS allowlist that includes rotating preview hosts) andFRONTEND_URL(set to a preview alias on Railway). Fix: newsite-urls.tsprovideshivejournalOrigin()/grapheneOrigin()/writeCafeOrigin()/seasonUrl()that reject*.vercel.appoutright 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.apphost 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 useprocess.env.ALLOWED_ORIGINSto 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 intoPLATFORM_ORIGINSand prepended to whateverALLOWED_ORIGINSenv var specifies. Earlier code required every origin to be inALLOWED_ORIGINS, so a missing/truncated env var quietly brokefetchcalls 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.mp4backdrop 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/signupwith the editor asnext. Secondary "🎲 Roll a prompt" link drives the random-bento generator. Backdrop opacity tuned (radial center 0.72, edges 0.97; per-form panels override withbg-amber-950/85 backdrop-blur-mdto 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_PASSWORDenv vars enable the integration (admin-trigger only),BLUESKY_AUTOPOST_ENABLED=trueflips on auto-fire fromtransitionStatus. Without creds the whole feature stays dark and entry points silently no-op. Speaks AT Protocol XRPC overfetchdirectly (no@atproto/apidep) —createSession(app-password login) → optionaluploadBlobfor image embeds →createRecordfor the post. Posts are idempotent onbluesky_post_uricolumns (added oncafe_contestsfor the Monday bento +cafe_contest_winnersper-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 #shortstorywith up to 2 of the DALL-E bento images embedded (skipped silently if oversize the 1MB blob cap); auto-fires onsubmission_openafter 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 #shortstorywith the existing 1200×630 cafe-winner OG card embedded as the unfurl visual; auto-fires onclosedafter 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/statusreports configured + autopost flags so the dashboard can hide buttons when dark;POST /:id/post-bento-to-bluesky+POST /:id/post-winners-to-bluesky(with optionalforce/categorybody fields) for manual triggers; "🦋 Post bento to Bluesky" + "🦋 Post winners" buttons render on contest cards only when configured. AT-URI helperatUriToWebUrl()convertsat://did:plc:.../app.bsky.feed.post/<rkey>tohttps://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 toseason-assets/cafe-winners/<contestId>/<category>-short.mp4, stampsshort_video_url+short_generated_atoncafe_contest_winners. Auto-fires sequentially after audio gen on transition toclosed(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 existinggoogleapisflow (#Shorts hashtag in title + description so YT classifies it correctly), stampsyoutube_short_id. The YouTube path is feature-flagged onYOUTUBE_OAUTH_*env vars (same set the season pipeline uses) and exposed atPOST /: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 whenyoutube_short_idis set. - TTS audio for cafe winners (
migration 172+cafe-winner-audio.ts service): when a contest transitions toclosed(manual admin or cron), the close-side hook firesgenerateAllWinnerAudio(contestId)in the background. Per winner: pulls the winning submission body, calls ElevenLabs TTS via the existingttsSegmenthelper (default voice = ElevenLabs "Rachel"21m00Tcm4TlvDq8ikWAM, override viaCAFE_WINNER_VOICE_IDenv), uploads toseason-assets/cafe-winners/<contestId>/<category>.mp3, and stampsaudio_url+audio_generated_atoncafe_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 whenaudio_urlis set. Admin retry routePOST /:id/regenerate-winner-audio(with optionalcategorybody 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-weekis an iframe-friendly minimal-chrome page rendering the active bento (with a "powered by write.cafe" footer linking back). Snippet generator at/write-cafe/embedshows 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/embedor/write-cafe/badgevia aCHROME_FREE_PREFIXESallowlist inClientOnlyComponents— embeds shouldn't ship a JQ chat bubble. (2) Writer badge —/api/og/write-cafe-badge?style=dark|lightreturns a 240×60 PNG ("I write at write.cafe" with the cup mark) generated edge-side via @vercel/og;/write-cafe/badgeis 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) PublicGET /api/cafe/contests/random-bento(was admin-only, was also dead because/random-bentogot 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/archivereturns 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/:weekStartresolves 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.rssrenders 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/:idincafe-contests.tsso 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 (avatarsbucket,cafe-bento/<contestId>/<kind>.pngprefix), and persists the URLs oncafe_contests.bento_image_urls.BentoBoxVisualrenders 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 atPOST /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-gridWritersRoomWidgetwith 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//privacypreambles +ContentRatingChip+AgeGate18Modal+ admin dropdown inSuperAdminPanel): 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 onprofiles.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_ratingenum (general/teen/mature) defaults togeneral. The listener-sideContentRatingChipshows on poster cards + season heroes (suppressed at general). When a season is ratedmature, the season detail page mountsAgeGate18Modal— 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 newPATCH /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 inbackend 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-stepwelcome_email_N_sent_attimestamps on profiles + a singlewelcome_emails_optoutexit 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 atGET /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 tonotifyCafeContestPhase'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 tocreator-notifications.ts+cafe-contests.ts route): when a contest enterssubmission_open, Resend fires a broadcast to every prior cafe-contest participant who hasn't opted out. Recipients =cafe_contest_submissionswriter_user_ids ∪ minusprofiles.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 routeGET /api/cafe/contests/email/unsubscribe?u=<userId>&s=<sig>flips opt-out. Hooked intotransitionStatusside-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 tocafe-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 viaregister(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 samesubmitStory()humans use — so the row looks correctly like the persona did the thing) andcafe_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 viacastVote()). 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 upai_assistedsubmissions 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 intorunCafeContestTick(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 byAI_PERSONA_BOTS_ENABLED=trueenv 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 theai_assistedcategory —typed_onlystays 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 tick —runCafeContestTick()evaluates every non-paid contest against calendar boundaries derived fromweek_start(Mon → submission_open → Sat → review_open → next Mon → closed) and transitions any that have crossed. Wired as asetIntervalin backend startup (15-minute cadence, 2-minute boot delay); also exposed asPOST /api/cafe/contests/tickfor admin-triggered manual runs. Idempotent —transitionStatus's status check makes re-runs no-ops. (2) Email notifications — newnotifyCafeContestPhasein creator-notifications fires on entry toreview_open("📜 Your ballots are ready — vote both rounds to stay eligible") andclosed(per-recipient: 🏆 winner copy / forfeit copy / generic reveal copy). Hooked intotransitionStatusside-effects so both manual admin transitions AND the cron path send emails. Same Resend-best-effort failure pattern asnotifyTipReceived. (3) Stripe Connect auto-payout — newpayContestWinner({ contestId, category })firesstripe.transfers.createto the winner's Connect account, stampspaid_via_stripe_transfer_id+paid_at, then auto-flips the contest topaidonce both categories are settled. Idempotent onpaid_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_onlywith paste-blocked editor +ai_assisted) so the AI question is two separate sports. State machine:draft→submission_open→review_open→closed→paid, transitions admin-driven (no cron yet). Pairing assignment fires on entry toreview_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 getis_eligible=falseflagged 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/:idstripswriter_user_iduntil status flips toclosed. 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/contestswitches 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 existingGET /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 inFeatureMenu.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. Newdrift_chapter_unlockstable withUNIQUE(user, season, episode)makes the unlock idempotent. ServiceunlockChapterWithCoinsmirrorsspendCoinsAsTip: debits wallet → writescreator_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 existingLockedChapterCard— 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 withDRIFT_CHAPTER_UNLOCK_COIN_COSTif 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. BackendGET /api/user/support-summarydoes 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 ofrelated_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 existingGET /api/writers/:handleandGET /api/story-seasons/:idresponses (no extra round-trip for the page) — both filtercreator_earningstotip+drift_currencysources (subscription_pool excluded since pool allocations aren't 1-listener signals) acrosspending/transferred/platform_retainedrows. 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. NewGET /api/creator/payouts/onboarding-statusendpoint 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'splatform_retainedearnings 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 inepisode-tips.ts+drift-coin-spend.ts): when a creator'screator_earningsrow 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.BrandedFooteris a small client switcher readinguseBrandedSurfaceto 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 inGrapheneClient+SeasonClient;/write-cafegets 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 onprofiles(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 onlower(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 existingavatarsbucket with ahero/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. NewGET /api/writers/check-username/:usernameendpoint 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 viahistory.replaceStateonce the writer claims a handle, no page reload. The season detail "by [writer]" credit prefersowner_usernameoverowner_user_idso 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 opensSupportWriterModalscoped 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 ausernamecolumn. The season detail hero now renders a small "by [writer]" credit when the season hasowner_user_id+ the owner has set a display name (full_nameor pseudonym), linking back to the writer's hub. Backend'sGET /api/story-seasons/:idwas extended to includeowner_display_namein the season payload (one extraprofilesround-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 inSeasonClient.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 hasdrift_enabledso the picker never offers a dead-end. Picker gated bytipping_enabledsite-setting (same as the chapter-footer tip CTA) and presence of at least one published chapter. Newsupport_writervalue added to GraphenePlusModal'ssourceprop 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.jsonwith{ title, body (HTML), folder, created_at, modified_at }per note. They upload that JSON; backend strips the HTML to plaintext via a focusedappleNotesHtmlToTexthelper (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 ajournal_entriesrow 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/importas 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 newrequireSuperAdminOrSeasonOwner(resolveSeasonId)factory. Four pre-bound resolvers:requireSeasonOwner(route param IS the season id),requireTreasureOwner(resolvedrift_treasures.season_id),requireTakeOwner(resolvestory_episode_takes.season_id), andrequireDecisionPointOwner(resolvedrift_decision_points.season_id). ThePOST /decision-pointsroute resolves the season inline because the season id lives in the request body, not a route param. The in-proseChapterSelectionMenu"🪙 Plant Drift treasure" affordance was already shown forcanMarkup(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. TheGET /api/drift/wallet/transactionsendpoint now joinsstory_seasons.titleserver-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 usesbefore(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 aSourceChip(💸 Chapter tip / 🪙 Drift coin tip / 🎧 Subscription pool / 🤝 Sponsorship / ✋ Manual grant) with per-stream tinting. TheGET /api/creator/payouts/earningssummary now includes aby_sourcerollup (count + creator_cents + gross_cents per source_kind), driving aBySourceStripabove 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 acreator_earningsrow writes at the configureddrift_creator_pct(default 90% — seecreator_revenue_splits.drift_creator_pctinmigration 160). No new schema needed; reuses the existingdrift_transactionsledger (withreason='coin_tip') andcreator_earnings(withsource_kind='drift_currency'). Coin-to-cents conversion uses a fixedNOMINAL_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 asplatform_retainedsoreconcileRetainedEarningscan 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:CoinTipModalshows 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 byseason.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_purchasestable is the audit trail (mirrorsepisode_tips):package_key+coins+amount_centssnapshot at purchase time,stripe_checkout_session_id(unique), status lifecycle (pending→paid/failed/refunded), andrelated_transaction_idFK todrift_transactionsso 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.createCoinPurchaseCheckoutpre-inserts a pending row + creates a Stripe Checkout session withmetadata.kind='drift_coin_purchase';recordCoinPurchase(called from the existing webhook inpayment.ts) is idempotent and credits the wallet via the samemutateBalancepath treasure claims use, withreason='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) + newfetchCoinPackages/startCoinCheckouthelpers inlib/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: newdrift_coins_purchasedPostHog event closes the funnel from "open the modal" to "coins delivered." - Drift — segment-level decision points (
migration 159):drift_decision_pointstable holds per-take pause-and-pick moments —paragraph_idx(the prose line listener UX pauses BEFORE),prompt(one-line setup),choicesjsonb array (2–3 entries withlabel/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 (status∈proposed/live/archived). Listeners only seeliverows; admins curate everything. Per-showdrift_pressure_modeflag onstory_seasonsdecides whether decision points without explicit countdowns inherit a 30s timer + auto-default-on-expiry (thrillers ON, cozy genres OFF). Service:drift-decision-points.ts—proposeForTake(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 inSuperAdminPanel(drift_enabled-gated) opensDriftDecisionPointsModal— 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.sqllays the schema for ALL three coupled mechanics: (1) branches asstory_episode_takeswith newparent_take_id+branch_label+is_drift_branchcolumns (forking UX ships in slice 2; takes already had version history so we don't need a new tree table), (2) wallets indrift_walletswithbalance+lifetime_earned+lifetime_spent, and (3) treasures indrift_treasures(admin-planted hidden marks listeners find on text selection) +drift_treasure_claims(audit + double-claim prevention). Per-seasonstory_seasons.drift_enabledflag opts a show in. Service:drift.ts— wallet ops viamutateBalance(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: smalldrift.ts hook(useDriftWallet+discoverTreasure+plantTreasure), 🪙 wallet pill in the read-page header (drift_enabled-gated),DriftDiscoveryListenerthat listens for text selections + POSTs to /discover for non-admin signed-in listeners, and a new "🪙 Plant Drift treasure" action inChapterSelectionMenufor 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 publishedseasonsand the admin-onlyhiddenSeasonspool — 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 byslimNavinNavbar.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: booleanper chapter on bothGET /api/story-seasons/:idandGET /api/story-seasons/:id/chaptersby comparingchapter_audio_rendered_atagainst the live take'spromoted_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 astale · re-renderchip 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.tsx→ReadClient.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: subtractsintro_seconds, walksparagraph_durationsto find the paragraph matching that audio time, then scrolls the matching[data-chapter-paragraph]into view.?karaoke=1is 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, theatparam is stripped from the URL so a refresh doesn't fight the user's later scrolling. - Karaoke mode on the read page (
ReadClient.tsxKaraokeProse): opt-in toggle in the page header (🎤 Karaoke, persisted inlocalStorageashj_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/chaptersendpoint now returnsintro_seconds(host narrator's "Chapter N: Title" preamble),paragraph_durations(per-journal_entry-segment audio length), andoutro_seconds, sourced fromseason_audio_segments.duration_seconds. The frontend subtractsintro_secondsfromcurrentTime(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-activeclass to avoid React re-rendering hundreds of spans on everytimeupdate; 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 · Audiorows. Audio playlist: rows inChapterPlaylistPlayer.tsxget 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.tsxnow re-fetches/chapterswith the auth header once super-admin is detected (mirrorsSeasonClient.tsx's pattern); the/chaptersendpoint 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?: booleanflag on theScriptCriticinterface (inscript-critics.ts). When true, the chapter-take critic runner (episode-take-critiques.ts) bypasses the GPT call and routes throughprose-cliche-detector.tsinstead. Findings still land in the sameepisode_take_critiquestable —model_keyis set to'lexical'andcost_centsto0so 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 withescapeForRegexand regex patterns with thegiflags, scans paragraph-by-paragraph, and emits oneChapterCritiqueFindingper match (capped atcritic.max_findings = 12per 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/admintab strip as🪶 Prose clichés. - Added to
DEFAULT_CRITIC_KEYSso 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_contextflag on theScriptCriticinterface. When set,episode-take-critiques.tsrunChapterCritiqueloads the current Story Bible (token-budgeted viabuildBiblePromptBlock(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_KEYSso 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.tsviarouter.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-seasonssuper_admins see every row; creators see their own + platform-owned (PostgREST OR filter onowner_user_id). - Public slate filter:
GET /api/story-seasons/publicaddsor(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 })instory-universes-db.tsfilters DB rows; hardcoded universes always included since they ship with the codebase as platform-shared lore. - Create stamps: POST
/api/story-seasonsand POST/api/story-seasons/universeslook up the caller's role and stampowner_user_id = req.user.idonly when caller is NOT super_admin. Super_admin creations stay platform-owned (NULL). - Publish toggle endpoint: PATCH
/api/story-seasons/:id/publishedflipsis_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/seasonsgets 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
requireSuperAdmininroutes/story-seasons.tswas widened to acceptsuper_adminORis_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/pipelinesusesrequireBusinessPartnerOrCreatorso 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/universesrely 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/universeto detach or move a season between universes. Standalone seasons (no universe) get their own section with the same picker. ReusesUniverseEditorModalfor create/edit. Backend: newPATCH /api/story-seasons/:id/universe(auth: per-:id ownership guard + creator can only attach to platform/own universes) and newGET /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 publicGET /api/story-seasons/universes/brandingendpoint, fetched once on mount), and thumbnail tile on the dashboard universe card. Each surface gracefully falls back to the gradient/icon layout whencover_image_urlis null. Editable viaUniverseEditorModal'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: newPATCH /api/story-seasons/universes/:key/reorderaccepts{ 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/universecalls; partial failures surface in the existing error banner. Selection clears on success. - Universe cover-image upload (
migration 151) —universe-coversSupabase 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'sCoverImageFieldwidget 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 inuniverse-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 withsource_notebook_idalready set, so the facets pipeline's "Scan journal" button is one click away — same notebook, deeper extraction. Bootstrap_input column stampsnotebook:{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.sqladdsstory_universes.source_notebook_id+last_scanned_atand a newuniverse_journal_facetstable (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.tsscanUniverseJournal(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.tsxgets aSourceJournalSectionwith 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 structuredcanonJSON, 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 editor —
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 likeUniverseEditorModalsecret_knowledge,relationships,physical,inner_life,backstoryare 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_via∈description | 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_inputagainstPOST /api/story-seasons/universes/bootstrapwith 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 parsesnotebook:<id>back out ofbootstrap_inputto 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 awindow.confirmbefore clobbering manual edits. Hardcoded universes (TURING_LOGS etc) have no_generated_viaso 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-keysreturns 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 posters —
PosterCardWithVersionnow leads its chip row with an "Edit →" link to/seasons/[id](where the existing in-page admin tools live). Only renders when the viewer hasversionStatusfor that season (super-admin or owner-creator), so public visitors see a clean poster. Standalone seasons swapped from the barePosterCardto 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 whenepisode_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.tsandchapter-stakes.tscompute "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.extendNovelaWithChapteracceptsextendTargetToso a creator can say "extend the novella to 12 chapters" rather than letting each "add chapter" silently bump target by 1; result includesarc_extended_by_oneso 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-countaccepts{ 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'sdramatic_role(cheap, no LLM) AND best-effort regenerates chapter spine + stakes (fire-and-forget; both LLM calls encode "final = chapter target"). Frontend:EpisodesAdminModaladds a📏 Plan: N chapterschip 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 toopening | 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.tsexportsderiveDramaticRoleFromPosition(cheap heuristic),computeDramaticRolesForSeason(re-derives all chapters in a season),validateDramaticRole(controlled-vocab guard for LLM input), andbuildDramaticRoleGuidance(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.generateNovelSeasonPlanasks 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 role —
PATCH /api/story-seasons/:id/episodes/:n/dramatic-roleaccepts{ 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 inEpisodesAdminModal— 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, andcomputeDramaticRolesForSeasonskips locked rows so a futuretarget_episode_countchange preserves manual overrides. NewPATCH /api/story-seasons/:id/episodes/:n/dramatic-role/unlockreleases 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 unpin —
EpisodesAdminModalnow shows a🔒 N of M pinnedchip 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: newPOST /api/story-seasons/:id/dramatic-roles/unpin-allclears every chapter'sdramatic_role_lockedflag and synchronously re-derives roles viacomputeDramaticRolesForSeason. 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 cell —
chapter_stakesJSONB gained an optionalpov_character_keyfield. 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.tsbuildPosterPromptis 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)PosterCardcomposites 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. NewPOST /api/story-seasons/:id/regenerate-posterroute +regenerateSeasonPoster()service do a poster-only regen (single DALL-E call, ~$0.08) leaving cast portraits untouched — surfaced as a🪧 Regen poster onlybutton in the visuals stage ofStageStrip. The original🎨 Regen visualsbutton 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),extendNovelaWithChapterdoesn't flag arc collapse. Backfill: novel-mode seasons → 'bounded' (preserves current behavior), journal-mode → 'serialized' (matches existing semantics).POST /api/story-seasonsaccepts optionalnarrative_modebody field;generateNovelSeasonPlanacceptsnarrativeModearg. Frontend:EpisodesAdminModalshows a📖 Bounded/♾️ Serializedbadge 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/seasonshas 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 bydramatic_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 totarget_episode_countthat 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_stakesJSONB onstory_episodesgained an optionalintensityfield that the chapter-stakes service asks GPT to fill in alongside the qualitative wants/obstacle/cost;computeChapterStakesForSeasonclamps 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/chaptersnow includesdramatic_role,chapter_stakes, andmood_impactper chapter to power the visualization. - Roles & rights UI at
/dashboard/admin/roles(super-admin only): lists every elevated user (anyone withrole !== 'user'OR a capability flag set) with toggle chips foris_creator+is_business_partnerand 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 — paginateslistUsersto find by email, upserts profile).super_adminpromotion 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/admintab 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.
| Signal | Source | Where it lands | Version |
|---|---|---|---|
| Craft beats | TypeScript constants (story-craft-principles.ts) | Per-chapter beat targets in 3 prompts | v1.10 |
| Internal engagement | Materialized view of platform reads/follows/tips | Per-genre top-quartile patterns in planner | v1.11 |
| Gutenberg corpus | Stats from ~50 public-domain works (no prose stored) | Per-position chapter-length norms in stakes | v1.12 |
| Bestseller market | Curated facts from ~80 modern bestsellers | Per-genre chapter-count defaults + example titles | v1.13 |
| Integration polish | Precedence note + concrete examples + intensity curves | Plumbing across all three prompt sites | v1.14 |
| Per-season framework selection | story_seasons.craft_framework column + planner pick | Only chosen framework's beats injected per chapter | v2.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:buildDramaticRoleGuidanceappends a CRAFT BEAT TARGETS block to every per-chapter role guidance.chapter-stakes.tsplanSectiontags each chapter line withCraft beats: SCT=X · McKee=Y · Hero=Zso the LLM computing stakes sees the full sequence.plan-persona-season.tsinjects 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 scores every published novel-mode season by season_engagement_quartilesreads_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()→ triggersREFRESH 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_worksliterary_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 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.bestseller_signals
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:
- 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.
- Concrete title-shape examples per genre in the planner prompt — pulls 3 recent bestseller titles per genre from the Phase 4 seed via
and presents them as grounding examples (NOT to copy). Concrete examples beat abstract instructions for title bias.getExampleTitlesPerGenre() - Top-quartile intensity curve injected into
chapter-stakes.tswhen 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
adds262_season_craft_framework.sqlstory_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
improveAllBehindChaptersruns against a season still on'auto'(i.e., the existing catalog hits the regenerate-to-v2.0 banner for the first time), it callsrecommendCraftFramework()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-signalsgained a "Craft framework rollout (v2.0)" panel — counts seasons total / picked / still on'auto', framework distribution chips, per-genre table. Powered byGET /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 viaPOST /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/seasonsopens 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, fulldramatic_role_guidance), plus the beats at that position in ALL three frameworks for comparison. Powered byGET /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(buildDramaticRoleGuidanceforwards it),season-novel-chapter.ts(SELECTs includecraft_frameworkand threads throughcallChapterPromptGPT),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]/readshows 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-behindendpoint; ⇧Shift skips audio re-render. Mirrors the heavierImproveAllBehindBanneron/seasons/[id]/overview. - Platform version:
v2.0 entry— first major-version bump. Frontendconstant must stay in sync (was the stale-constant bug that shipped v1.15→v2.0).CURRENT_PLATFORM_VERSION
v2.1 — Claude Sonnet writer engine + one-click batch regeneration
Migration 273addsv2_1_regeneration_runs(one row per "Start" click — aggregate counters + status) andv2_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.ts—enqueueV2_1Run()resolves the candidate seasons (is_published_to_graphene=true AND mode='novel'), filters to chapters with prose, inserts the items, then fires offprocessRunInBackground()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 underv2_1_regeneration_worker. - Routes:
routes/admin-v2-1-regeneration.tsmounted at/api/admin/v2-1—POST /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/:idevery 4s while running, stops polling on terminal state. - New takes auto-stamp
platform_version='v2.1'via the existinggetCurrentVersion()read ingenerateChapterTake. 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
constant must stay in sync — bumped toCURRENT_PLATFORM_VERSION'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:
- Apply migrations
251,252,253via the Supabase SQL editor. - 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). - Run
npx tsx scripts/seed-bestseller-signals.ts(Phase 4 data — instant, ~80 rows; supports--dry-run). - Visit
/dashboard/admin/quality-signalsand click↻ Refresh now(Phase 2 materialized view).
Ongoing:
- Phase 2 auto-refreshes weekly via the
engagement_quartiles_refreshcron (heartbeat-tracked). - Phase 3 is static unless we add works — re-run with
--idsto 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.
Settings — CafeBooksPreferencesSection 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.sql — conferences 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.ts — listPublicConferences({ 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.ts — GET /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).
EmberKiln brand landing + dropdown link
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/ogcard (mark + wordmark + tagline + "a graphene.fm studio"). - Brand dropdowns: surfaced as a sibling brand (relative
/emberkilnLink — no dedicated domain yet, serves on whichever brand host) in bothWriteCafeHeaderBrand.tsxandGrapheneHeaderBrand.tsx. - Routing: served via middleware pass-through on any brand host today; when
emberkiln.fm/.appis live, add a host block inmiddleware.tsthat rewrites root →/emberkiln(mirror thelovio.ioblock). - 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-motionaware) → 5 product pillars (reader / audiobook / scenes / screenplay / paperback) → a "see it in action" band → CTAs. - Discoverability: in the
sitemap(priority 0.8), theFeatures 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-authedGET /api/emberkiln/works/:id/artifacts(work id =story_seasons.id) →{ work, artifacts[] }with STABLEartifact_id=art_<seasonId>_<type>(QS upsert key), Lulu spec (interior/cover/pageCount/podPackageId/base_cost_cents best-effort fromprint_orders.cost) + digital audiobook. Slow renders viaPOST …/artifacts/prepare(fire-and-forget, 202; GET showspending: truemeanwhile): print assets → deterministicprint/<id>/export-{interior,cover}.pdf+export-manifest.json(pageCount) viagenerateSeasonPrintAssets; whole-book audiobook → chapter MP3s stitched toaudio/<id>/book-full.mp3(journal mode usesseason_publishings.audio_urlas-is). Pure core split intoemberkiln-artifacts-core.ts(golden-tested — the impure half's print/audio import chain can't load under ts-jest;suggestedPaperbackPriceMIRRORS print-storefrontquoteRetail, keep in sync). Impure:emberkiln-artifacts.ts; routes inroutes/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.tsgetEmberkilnShowcase()returns public novels + the forms they've taken (detected fromstory_episodes.chapter_audio_url/scene_projects/screenplay_projects; reader + paperback always). Public routeGET /api/emberkiln/showcase(CDN-cached, mounted/api/emberkiln). The landing server-fetches it (ISR 1h) →EmberkilnShowcase.tsx→ per-workShowcaseCard.tsxwith 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 eventsemberkiln_cta_open_studio/emberkiln_showcase_open/emberkiln_showcase_form_click/emberkiln_flow_output_click/emberkiln_storefront_open|quote|checkout(viaTrackedLink.tsx); server eventsemberkiln_print_checkout_started+emberkiln_print_order_paid(analytics-server.tsServerEventunion, 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_notesfb_reels— creator_id, title, fb_url (canonical), fb_video_id, duration_seconds, mood_tags[], themes[], content_warnings[], is_featured, status, ai_proposed_atfb_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_notessignal_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_atsignal_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.ts — buildMoodProfile(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.ts — proposeArtists(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-add — POST /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/X → open.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_modelcolumns from 256)looking_glass_quotes— verified quotes (text, source jsonb, context, original_date for anniversary lookup, is_featured,ai_proposed_at+ai_proposed_by_modelfrom 256)looking_glass_saves— user bookmarkslooking_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:
getAnniversaryQuotesForTodayreturns every quote whoseoriginal_dateMM-DD matches today, with computedyears_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/fontscoped 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.ts — proposeFigures(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_EMAILS→role='super_admin'BOOTSTRAP_CREATOR_EMAILS→is_creator=trueBOOTSTRAP_BUSINESS_PARTNER_EMAILS→is_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— paginatesauth.admin.listUsers()to find each email, then upserts the right field. Idempotent. Self-healing: emails not yet signed up are reported asnot_signed_upand re-checked on the next boot. - Boot hook: called fire-and-forget from
apps/backend/src/index.tsright afterapp.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 aprofilesrow — 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 ahandle_new_user()trigger onauth.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 →profiles404s +GET /api/chat/settings500s. Migration 352 codifies the trigger in version control + backfills the gap. Migration437extends the trigger to also copylanding_page_variant+ a 13+ age-attestation timestamp out ofNEW.raw_user_meta_data(theoptions.databag signup passes) — the client used to write those toprofilesdirectly 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-sideprofilesupsert (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) — seechat.tssettings handlers. - Frontend pages:
auth/signin·signup·callback·sso-bridge·forgot-password·reset-password - Cross-domain SSO bridge (
/auth/sso-bridge+ backendPOST /api/auth/sso-bridge): when a user clicks "Sign In" fromgraphene.fmorwrite.cafe's slim-nav, they're routed throughhttps://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 atreturn_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. Thebrand=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):useBrandedSurfacekeys offwindow.location.hostnameby default, sohivejournal.com/auth/signinrenders the HJ default look. The SSO bridge fallback breaks this — a write.cafe user clicking Sign In ends up athivejournal.com/auth/signin(no session on HJ yet) and would lose write.cafe chrome mid-flow. Fix:useBrandedSurface({ brandOverride })honors?brand=writecafe|graphenefrom the URL. The SSO bridge derivesbrandfromreturn_to's host viabrandFromUrl()and passes it forward./auth/signin+/auth/signuppreserve thebrandparam 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 readscode+next, callsexchangeCodeForSession, then 302s tonextor 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 ownhttps://<project>.supabase.co/auth/v1/callback. Supabase Auth → URL Configuration allowlists the three brand-domain/auth/callbackpaths soredirectTois 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_existscode) 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 callssupabase.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'sexchangeCodeForSessionsucceeds, we read the just-signed-in user'sprofiles.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, thenrouter.replace(next). Best-effort — profile-read failures don't block the redirect (dashboard's own checks will catch). - OAuth funnel tracking (
/auth/callback/route.tsimportsposthog-node): the password-path signup/signin handlers firesignup_completed/signin_completedPostHog 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'sexchangeCodeForSessionsucceeds, we fire the matching event server-side viaposthog-node'scaptureImmediate()so the funnel chart isn't underreporting Google conversions. Signup-vs-signin distinction is heuristic:user.created_atwithin 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 whenNEXT_PUBLIC_POSTHOG_KEYis unset (dev/CI safe). - Global PostHog identity sync (
PostHogIdentitySyncincomponents/analytics/PostHogProvider.tsx): the password-path handlers also callidentifyUser()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 insidePostHogProvider(so every route is covered) subscribes to Supabase'sonAuthStateChange+ does an initialgetUser()read on mount. On any signed-in state, callsposthog.identify(user.id, { email })if the current distinct_id doesn't already match; on sign-out, callsposthog.reset()so post-logout anonymous events don't attach to the previous identity. Covers OAuth callback completion (Supabase firesSIGNED_INonce 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.
- Endpoints:
apps/frontend/src/app/api/cron/ - Reference: docs/CRON_JOBS_SUMMARY.md, docs/CRON_SECRET_SETUP.md
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 inindex.ts) — SIGTERM/SIGINT flip adrainingflag (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_atcolumn every 30s while the job runs (interval dies with the process → stale heartbeat = orphaned). The singlerender_recoverycron (every 2min + ~20s after boot;CRON_REGISTRY+ toggle) runsrecoverStuckRenders()(season_publishings.status) +recoverStuckAcxExports()(story_seasons.acx_export_status): finds jobs stuckrenderingwith 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*_attemptscounter caps auto-resume (a user-triggered job resets to 0; the sweep bumps it; after 3 it marks the jobfailedwith 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 inadmin-pipeline-health.ts(which surfaced the problem; this fixes it). ThereclaimStuck(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 + wireisCronEnabled(key)at the entry point + add aCRON_LABELSentry inservices/system-health.tsfor 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,
invalidateCronToggleCachewrite-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/tickcost 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 wiring —
apps/backend/src/index.tsremoved the boot-time env-checksetTimeoutearly 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 callsawait 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-spendlink 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(defaultgpt-image-1),OPENAI_IMAGE_QUALITY_LOW|STANDARD|HIGH(defaultslow|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_settingswith 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-flagsinroutes/admin.tsreturns 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 fromprocess.env. Secrets / credentials are NOT surfaced. - Frontend:
/dashboard/admin/feature-flagsrenders the response grouped by category with a status pill per row (✓ Enabled / ○ Disabled for booleans, value chip for non-boolean settings). ConcatsNEXT_PUBLIC_*build-time vars (currently justNEXT_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 itsAUDIENCESarray + the footerAUDIENCE_LINKS+ the homepageAUDIENCESin 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-guides— Lantern, 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 toPOST /api/coaching/coach-interestwithsource: 'for_guides'→ pools intocoach_interest(same super-admin review as/for-coaches). Brand is a singleBRAND = '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(configurablefields/endpoint/source/card+ optionalextraPayload/onSubmitted/validateEmailhooks 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 bysource(for_therapists/for_teachers/for_english_teachers/for_science_teachers), pooling intoprovider_interest. The coaching skins (/for-coaches,/for-guides) instead POST toPOST /api/coaching/coach-interest(routes/coaching.ts) withsource(for_coaches/for_guides), pooling intocoach_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.ts—JOURNALING_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}viagenerateStaticParams(+dynamicParams=falseso unknown slugs 404). Two-column matrix + "what they do better" + both-direction verdicts. Next-15paramsis 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 vocabularyyes/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.sql—about_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.com≠example.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 inrehearsal_rate_limits(no migration). ReusesttsSegment/concatMp3Buffers/uploadAudio(→season-assets/about-that/),fetchReadableText(SSRF-guarded),complete(feature_keysabout_that.script.*,about_that.render.*; TTS quota billed to the embed OWNER). - Routes:
routes/about-that.tsat/api/about-that— owner endpointsrequireCreator; publicGET /embed/:id/config+POST /embed/:id/rendition(200 ready / 202 rendering+poll / 503 whenELEVENLABS_API_KEYunset).- 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 dumpsallowed_domains— a caller passes ONEhostand learns only whether it's allowed. Missing/paused embed →{ exists:false }at 200 (graceful degrade, not 404).getEmbedMetainabout-that.ts. Contract:crosstalk/contracts/about-that-embed.md(QuickSitesabout_thatblock 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 envPARTNER_QUICKSITES_SECRET, constant-timesafeEqual, 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 coreabout-that-provisioning-core.ts(golden-tested:hashGrantToken,safeEqual,checkGrantrevoked→401/scope→403/embed-narrow→403,normalizeVoiceBasesdropsthird_party,estCostUsd@ $0.30/1k). Serviceabout-that-provisioning.ts(mintGrant/listGrants/revokeGrant/resolveGrantRecord/recordPartnerRender/getPartnerUsage). Middlewareabout-that-partner.tsownerOrPartner— accepts owner JWT (creator-gated, unchanged) OR a partner grant (acts as the grant's owner, enforces embed-narrowing viareq.params.id). Consent v2 bright line: welcome = owner's own(self)/narrator, testimonial = always narrator — a partner grant can never reachthird_partyvoice 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 B1usageenvelope.billing_modeowner(default, HJ meters owner quota) vspartner(plumbed; accrual/packs deferred to the paid-mode infra). Contract:crosstalk/contracts/partner-provisioning.md. Apply migration 549 + setPARTNER_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-Grantoptional (→ 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/:instanceIdpolls. Pure coretalking-demo-core.ts(golden-tested:normalizeTourStepsclamp ≤24/~300 mirroring QS's generator,tourContentHash,tourCharCount); servicetalking-demo.tsreusesnarrateLine(render-once, cached per voice::line) + the provisioning auth/billing. Per-instance + ephemeral (30-day sweep) — deliberately NOT thestudio_demosapprove-before-publish catalog. Unchanged tour in the same voice = free cache hit (billed:false); owner-clone-without-a-consented-clone falls back to house + honestvoice_basis:'narrator'. House (no-owner) renders bill partner-direct (nullableowner_user_idonabout_that_partner_renders; surfaced in the B2 feed'spartner_directbucket). Per-partner daily capTALKING_DEMO_DAILY_CAP(default 300) → 429. Phase B — async caption-card MP4 (migration 551): passwant_mp4:true(+ optionaltitle) →/renderreturnsmp4_status:'rendering'and the MP4 renders fire-and-forget (talking-demo-video.tsrenderTalkingDemoMp4— instance-scoped variant ofdemo-video.ts, reuses the golden-testeddemo-video-argsbuilders +renderHtmlToPngcaption cards + bundled ffmpeg, driven by Phase-A's cachedaudio_urlper step); pollGET /:instanceIdformp4_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: passpage_url(the QS draft site) withwant_mp4:true→ HJ opens it in the shared headless Chromium (getSharedBrowser) and screenshots per step (SSRF-guarded viaassertPublicUrl, now exported from link-preview.ts). Positioning: QuickSites mirrors HJ'sdemo-actionsvocabulary at the block level — each step's optionalaction(scroll:#services,highlight:.price, or a bare#anchor, parsed byparseAction) 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/missingpage_urldegrades the whole MP4 to Phase-B cards.actionfolds intotourContentHash(+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 samerenderTalkingDemopipeline via super-adminPOST /api/studio-demos/talking-demo/preview+GET .../preview/:id(routes/studio-demos.ts) — paste stepped tour JSON, pick house/your voice, optionalpage_url+want_mp4→ plays per-step audio inline + polls/embeds the MP4 reel. Uses a dedicatedinternal-previewpartner (isolated from QS usage/caps) withownerUserId= the caller, sovoice:'owner_clone'resolves your own consented clone. No migration (reusestalking_demo_instances). End-to-end smoke PASSED both sides 2026-07-21 (audio 5/5 + real-screenshot MP4 reelrendering→ready, QS parse 1:1). Apply migrations 550+551 + setPARTNER_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 newauthorpreset — AUTHOR / READER "The Reading Room", code-validated inabout-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_enabledoncafe_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). Serviceabout-that-book-pitch.ts: one reservedabout_that_embedsrow 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-surfacesourceTextpath ongetOrRenderRendition— the storybodyis fed directly because the reader page is client-rendered (URL extraction would starve the script);sourceTextis 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, NOTrequireCreator— cafe writers aren't flagged creators); publicGET /book-pitch/:handle/:storyId(state) +POST /book-pitch/:handle/:storyId/rendition(render/poll, IP-throttled). Frontend:BookPitchPlayer.tsxself-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
coachingpreset (COACH / SEEKER "The Discovery Call", code-validated inabout-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-thatshowcase. No migration (presets are code-only). Onboarding = the coach records a Lovio clone + creates the embed.
- Embed metadata GET (partner editor-preview / provisioning host-check): public
- Frontend: loader
public/about-that.js(data-embed, optionaldata-url/data-host/data-width→ 148px iframe); player/about-that/player/[embedId](noindex,useSearchParamsinside 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 atTRY_GALLERY_CAP(25) non-error tries ever, then the form flips to signup. EndpointsGET/POST /api/about-that/try,GET /try/:id(202 + background render + poll —renderTryInBackgroundis fire-and-forget so proxy timeouts can't kill it). Guards:normalizeTryUrldedupe, 3 submissions/IP/day, OpenAI moderation on extracted text pre-voice, cap-race recount. UI:TryItClient.tsxon the landing — gallery cards render page-visual → arrow → player:og_image_url(migration 497) populated fire-and-forget at submit viagetLinkPreview(SSRF-guarded,link_previewscache), favicon-tile fallback +onErrorfor dead image URLs.- Single-voice registers (
migration 545) — kinds beyondsummary/eli10/pitch_panel. Unlike presets, a new kind needs a migration (theabout_that_renditions.kindCHECK from migration 493). Addedtl_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) in545;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: …") in546;author_note(the author's own "why I wrote this" note behind a blog/Substack post, grounded, no invented biography) in547. All opt-in:enabled_kindsdefault stays{summary,eli10,pitch_panel}. Prompt mapSINGLE_VOICE_DIRECTIVESinabout-that-core.tsis exhaustive overSingleVoiceKind(missing directive = compile error); summary/eli10 wording kept byte-identical. Render path (single-voice branch, feature_keyabout_that.render.<kind>) is kind-generic — no per-kind service change. Toggle in/dashboard/about-thatKINDS. Golden-tested. Apply migration 545. whats_newregister — 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 tableabout_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,resolveWhatsNewDiffinabout-that.tsrecords the current version (upsertignoreDuplicates) 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 keywhatsNewCacheKey(inabout-that-core.ts, pure + golden-tested) folds both prior + current text (prior via a short hash so the key stays fixed-length) atoprenditionCacheKey, so voice/tone/preset/lang still participate. PromptbuildWhatsNewPrompthas 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).resolveWhatsNewDiffis best-effort (a version-log DB hiccup degrades to a first-sighting render, never fails the rendition). Single-voice +gpt-4o-mini, feature_keyabout_that.script.whats_new. Opt-in (enabled_kindsdefault unchanged); toggle in the dashboardKINDS. Apply migration 555.- Multi-language renditions (
migration 548) — "your site, in your voice, in Spanish." TTS is alreadyeleven_multilingual_v2, so a language = the SCRIPT written in it (no new TTS model/path). SetABOUT_THAT_LANGUAGESinabout-that-core.ts(code-validated, no CHECK): en/es/fr/de/pt/it/ja/zh/hi. Per-embedenabled_languages(about_that_embeds, default{en};normalizeLanguagesalways keepsen).langis a cache-key dimension inrenditionCacheKeybutenfolds in nothing (byte-identical → existing English audio never re-renders / re-keys); each non-en language caches independently (about_that_renditions.langdescriptive; 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 Englishspeakertokens, translatestext).resolveLangdegrades an off-list/stale?lang=to English (never 403). Config exposeslanguagesonly when >1;PlayerClientshows a<select>+ honors?lang=es; dashboard create-form + per-embed 🌍 editor (PATCHenabled_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_CONFIGinabout-that-core.tsre-skins thepitch_panelpersona pair + player label per embed; validated in code againstABOUT_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 (parsePanelScriptcoerces speakers by first-letter prefix) and is a cache-key dimension. Picker in/dashboard/about-thatPRESETS. Golden-tested per-preset (label + grounded prompt + distinct cache key). Full table:docs/product/ABOUT_THAT.md. - Preset dimension (
migration 514addsabout_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 perPRESET_CONFIG[preset].pitcher);GET/POST /api/about-that/trytake?preset=/{preset}. Real-estate "try your listing" (RealEstateTryClient.tsxon 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:submitTryaccepts{text, title}as an alternative tourl— JS-widget listings render no server-side text, so the agent pastes the details and it renders straight from those (syntheticpaste:<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 dashboardstartPlan('agent'|'brokerage')hits the correct/api/payment/about-that/checkout(the old/api/about-that/checkoutwas 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.
- Single-voice registers (
- Brokerage tier front door (
migration 515about_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-adminGET /brokerage-leads. Best-effort Slack notify on each new lead (notifyBrokerageLead, opt-inABOUT_THAT_LEADS_SLACK=#channel/C…/email-to-DM, gated onslackEnabled(), fire-and-forget — mirrorsnotifyLighthouseSignup) 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 516brokerages+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 oneabout_that_brokeragesubscription — the "bill-together" magic viaisBrokerageCoveredextendingrequiresAboutThatPlan(about-that-billing.ts; plan keys extracted toabout-that-billing-plans.tsto break the billing↔brokerage cycle). Serviceabout-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(useSearchParamsin<Suspense>). Apply migration 516. - Brokerage seat model — Phase 2: one snippet, right agent (
migration 517brokerage_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.jsloader forwards it to the iframe player →PlayerClientsendsagentin the rendition/config calls →getOrRenderRendition/eagerRenderForPagecallresolveAgentVoice(embed.user_id, slug):resolveAgentUserIdgates 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-agentagent_slug(email-localpart, per-brokerage unique viauniqueAgentSlug) shown on the brokerage dashboard with adata-agenthint. Agent-specificvoiceKey→ renditions cache per-agent automatically. PureslugifyEmailgolden-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 —agentfor 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.toStringSVG, server-side, mirrors the season press-kit) and lays outrider(yard-sign) /sheet(per-house open-house) /card(per-agent — pointurlat your bio) print-optimized (@pageper 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, defaultfounder, NO db CHECK — code-validated againstABOUT_THAT_PRESETS): a preset re-skins thepitch_panelpersona pair + player label per embed (kinds never change; summary/eli10 unaffected; the try wall renders any preset as of migration 514).PRESET_CONFIGin about-that-core.ts:founderFOUNDER/INVESTOR "The Pitch Panel" (byte-identical to pre-preset prompt — golden-locked),candidateCANDIDATE/RECRUITER "The Interview Panel" (resume/portfolio pitches the owner),agentAGENT/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 (parsePanelScriptcoerces 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_renderdefault true): the player passes?url=on its config fetch; the config route fire-and-forgetseagerRenderForPage(service) which renders every enabled kind for that page on VISIT instead of first listen. Two-switch throttle: globalabout_that_eager_renderinCRON_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-embedeager_render(owner opt-out — renders bill the owner). All lazy-path guards reused verbatim viagetOrRenderRendition(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[]})inabout-that.tsloopscreateEmbedto mint N embeds at once — one per voice character — all gated to a single domain, eachvoice_mode:'narrator'with a distinctnarrator_voice_idandeager_render:false(lazy, so several players on one page don't all render on load). Vertical-agnostic viapreset(agent = realty agent roster, candidate = job-seeker cohort, founder = startup lineup — defaults toagent). 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.tsxcollapsible 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_enableddefault false + tableabout_that_faq_answers): the INTERACTIVE register — a visitor question answered STRICTLY from the page's extracted text (grounding is the product; the prompt emits aNOT_ON_PAGEsentinel →answerable:falsegraceful decline over invented hours/prices/policies). Opt-in capability flag (NOT anenabled_kindsmember — it takes input) with its own publicPOST /api/about-that/embed/:id/ask {url, question}(200 answer / 202 answering+poll). Guards: domain gate, OpenAI moderation on the visitor question (isPageTextFlaggedreused), SSRF fetch, per-embed daily cap (100) + tighter per-IP throttle (isFaqRateLimited, 20/IP/day, namespacedrehearsal_rate_limits), content-hash cache+claim keyedfaqCacheKey(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 configfaq:true; needs height so it's on the hosted/listenpage + adata-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 keysabout_that.faq.script/about_that.faq.render(billed to owner). - Voice Welcome (
migration 507about_that_welcomes; contractcrosstalk/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 QuickSitesvoice_welcomeblock plays with its own UI (no iframe). Owner-scopedPOST /api/about-that/:id/welcome {script}(creator-gated) →{ welcome_id, audio_url, cached };renderWelcomereusesresolveVoice+ttsSegment(billed to owner) +uploadAudio, cached bywelcomeCacheKey(script+voiceKey)(re-submit = free hit; edited script re-renders, old MP3 kept — baked into published sites, must never rot). PurenormalizeWelcomeScript(≤600) +welcomeCacheKeygolden-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 508about_that_testimonials; contractcrosstalk/contracts/testimonial-audio-endpoint.md): a third-party customer quote rendered ONCE to a permanent public MP3 the QuickSitestestimonial_audioblock 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 (renderTestimonialcallsresolveNarratorVoice, which ignores the embed'svoice_modeeven when it'sown_clone— putting a customer's quote in the owner's cloned voice would be fabrication). Owner-scopedPOST /api/about-that/:id/testimonial {quote}(creator-gated) →{ testimonial_id, audio_url, cached },feature_key: about_that.testimonial. Cache keytestimonialCacheKey(quote+narratorVoiceKey)carries a distincttestimonialprefix so it can never collide with — or reuse — a welcome MP3 of identical text+voice (golden-tested alongsidenormalizeTestimonial≤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-entryerrorrather than a failed batch. Apply migration 508. - Play/completion analytics (
migration 502,about_that_statsdaily rollup — the retention hook + paid-tier justification): the player fires a best-effortPOST /embed/:id/event {url,kind,event}beacon onplay(once per rendition — resume-after-pause doesn't recount) +complete(audio ended);recordPlayEventvalidates 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 readoutGET /:id/stats→getEmbedStats(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 publicGET /api/story-seasons/voice-bank(VOICE_BANK + narrator w/ ElevenLabs CDNpreview_url), own-clone preview fromGET /api/lovio/voice(preview_audio_url); saves via the existing PATCH (voice_mode/narrator_voice_id). OwnerPOST /api/about-that/:id/rerender(creator-gated) →rerenderEmbed: replays distinct (page_url, kind) pairs (≤40) throughgetOrRenderRenditionfire-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; reusesPlayerClientwith itsframe='page'prop; same public endpoints + domain gate, no new render path; Next-15 Promise params + Suspense arounduseSearchParams). Dashboard rendition cards: "Copy listen link" + "Download MP3" (cross-origindownloadattr is ignored → fetch→blob→objectURL,window.openfallback).- GitHub README badge: shields-style flat SVG ("▶ hear this page | About That") for dev-founder repos —
aboutThatBadgeSvg()inabout-that-core.ts(pure, golden-tested; deterministic Verdana-ish width estimator, XML-escaped, active=amber+play-glyph / inactive=muted "unavailable"). Public routeGET /api/about-that/embed/:embedId/badge.svginabout-that.ts routesalways 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 scopednext.config.jsrewrite (/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.
- GitHub README badge: shields-style flat SVG ("▶ hear this page | About That") for dev-founder repos —
- Real-estate paywall (dark-launched): the
agent/Open-House preset is the paid vertical ($79/mo agent, $399/mo brokerage);founder+candidatestay free.services/about-that-billing.ts:aboutThatPaidMode()(envNEXT_PUBLIC_ABOUT_THAT_PAID_MODE, default OFF = no gate anywhere),isAboutThatSubscriber(readssubscriptionsplan_keyabout_that_agent/about_that_brokerage— reuses graphene rails, NO migration),requiresAboutThatPlan(embed), and the PURE unit-testedshouldGateRender({paidMode,preset,subscribed}). Enforced inabout-that.ts:getOrRenderRendition+askAboutPagethrow 402 for an unpaid agent embed;eagerRenderForPageskips silently. CheckoutPOST /api/payment/about-that/checkout {plan}+priceToPlanKey(existing/webhookactivates); statusGET /api/about-that/billing/status. Dashboard shows the upgrade CTA on agent embeds only when paid mode is on + unsubscribed;/about-that/for-real-estatecarries 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
/features—features/page.tsx/compare—compare/page.tsx/compare/journaling,/compare/hivejournal-vs-*,/best-journaling-apps-2026— see "Journaling comparison SEO cluster" above/about/jq—about/jq/page.tsx/jq-bridge—jq-bridge/page.tsx/jq-extension—jq-extension/page.tsx/open-energy(legacy),/open-energy/experiments,/open-energy/contributors/dreamproand 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)/blogand/blog/[slug]—blog/page.tsx— blog index + dynamic article pages/learnand/learn/[slug]—learn/page.tsx— educational content pages/vision—vision/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 574addsfamily_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);resolveWallViewfiltersrollup.membersto the set. Saved throughupdateWallDisplay. -
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.tswith a pure, unit-testeddecideAccessevaluator (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 577addsfamily_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 viagetLinkPreview, 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.recordUsageaccumulates minutes;evaluateAppAccesssums today's usage vs the member's cap and returnscap-reached(app-specific cap → that app's usage; member-wide → total). PurecapStatushelper is unit-tested. Wired: each allowed JQ turn (wall Ask-JQ + kid-chat) records ~1 min;POST /api/family/screentime/usagefor 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 topin_version) is remembered until the PIN is changed/cleared, which bumps the version and re-locks every device. Servicefamily-wall-pin.ts(HMAC-JWT_SECRETpin hash + device token, constant-time compare).resolveWallView(..., {deviceToken, enforcePin})returns{locked:'pin'}→ the wall shows a PIN screen;POST /wall/view/:token/unlockissues the device token; owner sets it viaPOST /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 581addsfamily_wall_displays.bg_source+bg_tags;screensavergainsken_burns): per-device rotating Unsplash imagery.family-unsplash.tsunsplashPhotos(query)(needsUNSPLASH_ACCESS_KEY; 30-min cache; hotlinks + attribution + download-event per Unsplash terms; degrades to []).resolveWallViewfetches wallpapers whenbg_source='unsplash'OR screensaver=ken_burnsand passes them into the WallView (bg_source,wallpapers). Wall render rotates every 30s (bg crossfade or Ken Burns pan/zoomwallKenBurnskeyframes) 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 viaresolveWallpaperQuery). Apply migration 581 + set UNSPLASH_ACCESS_KEY. -
Family growth nudges (
migration 582addsfamily_members.growth_nudges text[]): parent-enabled gentle activity invitations a child's JQ avatar offers on their launcher. Catalog + kid copy infamily-growth.ts(GROWTH_NUDGES: story/EmberKiln, graphene-kids, citizen-science, workout-kids, dreampro-step);resolveGrowthNudgesrotates ≤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/launchernow returns{tiles, nudges}). Guardrails: invitation-not-nag, parent-authored + parent-visible.