# Project export: Dream Book

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Speak a story out loud, and Dream Book transforms it into a magical illustrated book that reads back and talks with you, your children, family and friends.
- Devpost: https://devpost.com/software/dream-book-jg0qno
- GitHub: https://github.com/OlegBezr/ai_hack_2026
- Demo: https://ai-hack-2026-nine.vercel.app/login
- Video: https://player.vimeo.com/video/1203254509?byline=0&portrait=0&title=0#t=
- Team: 3 GitHub contributor(s) — Oleg Bezrukavnikov (42 commits), Arina Sokolova (7 commits), Claude Opus 4.8 (1 commits)

## Devpost submission (written by the team)

### Inspiration

Dream Book started from a very personal place. We are a married couple, and we often talk about our future, family, and how we would want to raise children one day. One thing we kept coming back to was storytelling: the small bedtime stories parents make up, the magical books children remember, and the feeling of reading something together before sleep. That became the inspiration for Dream Book. We wanted to build an experience that could be interesting for both adults and children. For adults, it should feel like an easy creative tool: just speak your idea, and the app helps turn it into something beautiful. For children, it should feel magical: the story becomes a real book, with illustrations, page turns, narration, and the ability to talk back to the book. Our goal was not just to generate text. We wanted to create a small "wow" moment — the feeling that your own voice can become a real illustrated storybook.

### What it does

Dream Book is a voice-first AI storybook. The user tells a story out loud, and the app turns it into an illustrated, narrated book. The experience begins with Deepgram. Instead of typing a prompt, the user speaks naturally, the way someone would tell a bedtime story. Deepgram transcribes that voice input, and the app shows the transcript back as editable text. This review step is important because the user stays in control before generation begins. Once the user is happy with the transcript, they tap Create book. From there, Dream Book improves the spoken story, turns it into a warm children’s story, splits it into page-level content, generates illustrations, creates narration audio, and opens the result as an interactive book. The user can flip through the book, play the narration for each spread, and experience it like a real read-aloud picture book. We also added a voice conversation mode powered by Deepgram, where the user can talk with the book after reading. They can ask questions about the story, discuss the characters, or keep engaging with the world they created. This makes voice part of the entire experience: the story starts with voice, becomes a book, and then continues as a spoken conversation with that book.

### How we built it

We started by building Dream Book in Flutter because we wanted a highly customized interface and liked the idea of supporting both mobile apps and the web from one codebase. Since the product is very visual, we knew we would need a lot of custom UI: page layouts, storybook styling, animations, audio controls, and a polished reading experience. However, we ran into challenges with several dependencies we needed for rendering the book experience. Some packages did not behave the way we needed, especially for the kind of animated book interface we imagined. We also explored Rive because we hoped to use ready-made interactive book animations, but we could not find anything that fit the product well enough. That pushed us to rethink the implementation. In the end, we moved to a custom web implementation built from scratch. This gave us more control over the book UI, the page layout, the playback controls, and the overall visual experience. Claude helped us rewrite and reuse a lot of the frontend logic, and we were still able to keep our existing backend architecture. The core pipeline works like this: The user speaks a story idea. Deepgram transcribes the voice into text. The user reviews and edits the transcript. The backend improves the story and splits it into page-level content. Each page gets its own text, illustration prompt, generated image, and narration audio. The frontend displays the result as an interactive book. The user can later talk with the book using voice. For the backend, we used Supabase. We used it for authentication, storing user profiles, story data, generated page content, audio, and illustrations. Deepgram was central to the project. It powered the voice input that starts the story and the voice interaction that lets users talk with the book afterward. We wanted voice to be more than a feature — it had to be the main way the user creates and experiences the product. Deepgram let us make the first step feel natural: instead of filling out a form or typing a prompt, the user can simply tell a story out loud. We then show that transcript back as editable text, so the user stays in control before generation begins. Later, Deepgram also powers the “talk with the book” experience, where the user can ask questions about the story by voice and continue interacting with the world they created. For image generation, we used Midjourney through an MCP server provided by the Midjourney team. This was one of the most interesting parts of the project because Midjourney does not provide a traditional public image generation API. We had to understand how the MCP server worked and how to connect it to our backend so images could be generated automatically for users. We also integrated Sentry across the whole stack: our Deno backend, the original Flutter app, and the final Svelte web app. This was especially useful as we switched from Flutter to Svelte during the hackathon, because it helped us make sure the core product flow did not silently break during the rewrite. Sentry let us track frontend errors, backend issues, cloud function failures, slow endpoints, and problems with external services like Deepgram and Midjourney. With so many moving pieces - voice input, story generation, image generation, narration, storage, and playback - Sentry helped us keep the experience reliable across systems. Our sentry org: https://ai-hack-2026.sentry.io/

### Challenges we ran into

One of the biggest challenges was making Dream Book feel like a real book, not just another AI text generator. A normal AI app can stop at generating a story, but we wanted the output to feel polished and magical. That meant thinking about page layout, pacing, illustrations, narration, audio controls, and the feeling of flipping through a storybook. Another major challenge was changing our frontend direction. We originally planned to use Flutter, but the dependencies and rendering limitations made it difficult to achieve the exact book experience we wanted. Switching to a custom web implementation was not easy, especially during a hackathon, but it gave us much more control over the final experience. Midjourney integration was also challenging. Since there was no standard API flow, we had to learn how to use the MCP server and connect it into our backend generation pipeline. We needed image generation to feel automatic from the user's perspective, even though the underlying integration required a lot of experimentation. We also had to coordinate many AI and media steps into one smooth flow: speech transcription, transcript editing, story rewriting, page splitting, image generation, narration generation, storage, and frontend playback. If any one part failed, the whole book could feel broken. A lot of our work went into making that pipeline reliable enough that a user could simply speak, wait, and receive a complete book.

### Accomplishments we're proud of

We are proud that Dream Book feels like more than a prompt-to-output generator. The user does not just type something and receive text. They speak a story, review it, watch it become a book, listen to narration, flip through pages, and then continue the conversation by voice. We are also proud of the full multimodal pipeline we built during the hackathon. Dream Book combines voice recognition, story generation, page-based structuring, image generation, narration, storage, monitoring, and an interactive reader interface. The voice conversation mode is another part we are proud of. It makes the book feel more alive. Instead of ending when the story is generated, the experience continues: the user can ask about the story, talk about the characters, and interact with the world they created. Most of all, we are proud that the product feels warm and human. It started from a personal idea about future bedtime stories, and it became something real that people can use, read, hear, and talk to.

### What we learned

We learned that voice changes the emotional feel of an AI product. Typing a prompt feels like using a tool, but speaking a story feels much more personal. It makes the user feel like they are performing, imagining, and creating in a natural way. We also learned how important structure is when building with generative AI. Instead of asking the model for one long story, we had to design the output as a book: title, pages, page text, image prompts, narration, and conversation context. That structure made it possible to turn AI output into an actual product experience. Another big lesson was that the "last mile" of AI products is often not the model itself, but the orchestration around it. The hard part was not only generating text or images. It was making all the pieces work together: voice input, editable transcripts, page-based story generation, illustrations, audio, storage, monitoring, and a polished reader interface. We also learned that sometimes changing direction is the right technical decision. Moving away from our initial Flutter plan was difficult, but it helped us build a better experience for this specific project.

### What's next

Next, we want to make Dream Book more interactive and more personalized. We would like users to be able to reshape the story while reading it, using voice commands like "make the ending happier," "add a dragon," or "continue the story." We also want to add more personalization, such as choosing the child's name, age, favorite animals, preferred story tone, and reading level. Another important next step is stronger safety and moderation. Since Dream Book can be used for children's stories, we want to add automated content moderation, better age controls, and parent settings. We also want to improve the reading experience with richer animations, smoother page turning, character voices, and a bedtime mode that slowly makes the narration softer and the visuals calmer. Longer term, Dream Book could become a library of personal family stories: books created from a parent's voice, saved forever, and reread by children as part of their bedtime routine.

## README (from the GitHub repository)

This is a monorepo.

- Flutter frontend

## Detected evidence (automated analysis)

Indexed codebase: 198 recognized source files, 922 KB.
- C (language) — detected in the code
- CSS (language) — detected in the code
- Dart (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Kotlin (language) — detected in the code
- Python (language) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Svelte (technology) — detected in the code
- Swift (language) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- React (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers
- AI coding agent: Codex — evidence: config files committed to the repository
- AI coding agent: Cursor — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (120 of 259)

```
.claude/launch.json
.codex/config.toml
.fvmrc
.gitignore
.mcp.json
.vscode/settings.json
ai_hack_2026.code-workspace
DEVELOPMENT_LEGACY.md
DEVELOPMENT.md
docs/deepgram.md
docs/midjourney.md
docs/plan.md
docs/sentry.md
dream_book/.claude/launch.json
dream_book/.cursor/mcp.json
dream_book/.gitignore
dream_book/.mcp.json
dream_book/.metadata
dream_book/.vscode/mcp.json
dream_book/analysis_options.yaml
dream_book/android/.gitignore
dream_book/android/app/build.gradle.kts
dream_book/android/app/src/debug/AndroidManifest.xml
dream_book/android/app/src/main/AndroidManifest.xml
dream_book/android/app/src/main/kotlin/com/example/dream_book/MainActivity.kt
dream_book/android/app/src/main/res/drawable-v21/launch_background.xml
dream_book/android/app/src/main/res/drawable/launch_background.xml
dream_book/android/app/src/main/res/values-night/styles.xml
dream_book/android/app/src/main/res/values/styles.xml
dream_book/android/app/src/profile/AndroidManifest.xml
dream_book/android/build.gradle.kts
dream_book/android/gradle.properties
dream_book/android/gradle/wrapper/gradle-wrapper.properties
dream_book/android/settings.gradle.kts
dream_book/assets/rive/book.riv
dream_book/devtools_options.yaml
dream_book/ios/.gitignore
dream_book/ios/Flutter/AppFrameworkInfo.plist
dream_book/ios/Flutter/Debug.xcconfig
dream_book/ios/Flutter/Profile.xcconfig
dream_book/ios/Flutter/Release.xcconfig
dream_book/ios/Podfile
dream_book/ios/Podfile.lock
dream_book/ios/Runner.xcodeproj/project.pbxproj
dream_book/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
dream_book/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
dream_book/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
dream_book/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
dream_book/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
dream_book/ios/Runner.xcworkspace/contents.xcworkspacedata
dream_book/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
dream_book/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
dream_book/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
dream_book/ios/Runner/AppDelegate.swift
dream_book/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
dream_book/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
dream_book/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
dream_book/ios/Runner/Base.lproj/LaunchScreen.storyboard
dream_book/ios/Runner/Base.lproj/Main.storyboard
dream_book/ios/Runner/Info.plist
dream_book/ios/Runner/Runner-Bridging-Header.h
dream_book/ios/Runner/SceneDelegate.swift
dream_book/ios/RunnerTests/RunnerTests.swift
dream_book/lib/audio/background_music.dart
dream_book/lib/audio/music_controls.dart
dream_book/lib/audio/page_narration.dart
dream_book/lib/book/book_reader_page.dart
dream_book/lib/book/book_voice_chat.dart
dream_book/lib/deepgram/deepgram_service.dart
dream_book/lib/deepgram/read_bytes_io.dart
dream_book/lib/deepgram/read_bytes_web.dart
dream_book/lib/deepgram/read_bytes.dart
dream_book/lib/deepgram/voice_agent_service.dart
dream_book/lib/deepgram/voice_audio_stub.dart
dream_book/lib/deepgram/voice_audio_web.dart
dream_book/lib/deepgram/voice_audio.dart
dream_book/lib/demos/deepgram_demo.dart
dream_book/lib/demos/midjourney_demo.dart
dream_book/lib/demos/rive_book_demo.dart
dream_book/lib/demos/turnable_page_demo.dart
dream_book/lib/home_page.dart
dream_book/lib/main.dart
dream_book/lib/midjourney/midjourney_auth.dart
dream_book/lib/midjourney/midjourney_client.dart
dream_book/lib/midjourney/midjourney_models.dart
dream_book/lib/profile/data/profile_model.dart
dream_book/lib/profile/data/profile_repository.dart
dream_book/lib/profile/screens/profile_screen.dart
dream_book/lib/stories/auth/auth_providers.dart
dream_book/lib/stories/data/models.dart
dream_book/lib/stories/data/stories_repository.dart
dream_book/lib/stories/router.dart
dream_book/lib/stories/screens/create_story_screen.dart
dream_book/lib/stories/screens/login_screen.dart
dream_book/lib/stories/screens/reader_screen.dart
dream_book/lib/stories/screens/stories_list_screen.dart
dream_book/lib/stories/screens/story_editor_screen.dart
dream_book/lib/stories/supabase_config.dart
dream_book/lib/theme/app_theme.dart
dream_book/lib/theme/magical_background.dart
dream_book/lib/theme/magical_widgets.dart
dream_book/packages/turnable_page/analysis_options.yaml
dream_book/packages/turnable_page/CHANGELOG.md
dream_book/packages/turnable_page/example/analysis_options.yaml
dream_book/packages/turnable_page/example/lib/main.dart
dream_book/packages/turnable_page/example/lib/pdf_view.dart
dream_book/packages/turnable_page/example/pubspec.yaml
dream_book/packages/turnable_page/example/README.md
dream_book/packages/turnable_page/example/web/index.html
dream_book/packages/turnable_page/example/web/manifest.json
dream_book/packages/turnable_page/lib/src/collection/page_collection_impl.dart
dream_book/packages/turnable_page/lib/src/collection/page_collection.dart
dream_book/packages/turnable_page/lib/src/enums/animation_process.dart
dream_book/packages/turnable_page/lib/src/enums/book_orientation.dart
dream_book/packages/turnable_page/lib/src/enums/flip_corner.dart
dream_book/packages/turnable_page/lib/src/enums/flip_direction.dart
dream_book/packages/turnable_page/lib/src/enums/flipping_state.dart
dream_book/packages/turnable_page/lib/src/enums/page_density.dart
dream_book/packages/turnable_page/lib/src/enums/page_orientation.dart
dream_book/packages/turnable_page/lib/src/enums/page_view_mode.dart
[139 more files omitted for size]
```

### Dependencies

- svelte_dream_book/package.json: @eslint/compat@^1.2.5, @eslint/js@^9.18.0, @sentry/sveltekit@^10.59.0, @supabase/supabase-js@^2.47.0, @sveltejs/adapter-vercel@^6.3.4, @sveltejs/kit@^2.66.0, @sveltejs/vite-plugin-svelte@^7.1.2, @tailwindcss/vite@^4.0.0, eslint@^9.18.0, eslint-config-prettier@^10.0.1, eslint-plugin-svelte@^3.0.0, globals@^16.0.0, prettier@^3.4.2, prettier-plugin-svelte@^3.3.3, svelte@^5.19.0, svelte-check@^4.1.0, tailwindcss@^4.0.0, typescript@^5.7.0, typescript-eslint@^8.20.0, vite@^8.0.16

### Recent commits (newest first)

- Switch svelte_dream_book to adapter-vercel for Vercel deploy
- setup for email auth
- deepgram fixes, svelte feature parity with flutter
- svelte infra improvements
- tighten functions security
- small logic fix for supabase functions
- fix small svelte bugs
- update development guides, bump svelte versions
- update code workspace
- svelte clean up book logic
- get rid of react efforts
- draft svelte and react impl
- feat: talk-through story creation with Claude + backend Sentry
- voice to voice chat with the book in flutter
- sentry monitoring integration
- attempt to improve turnable_page experience
- web research folder
- music bug fix
- Added background music
- improved page customization and reading mdoe draft

## Key source files (fetched from GitHub, selected and truncated for size)

### DEVELOPMENT.md

```markdown
# Local development

How to run the Supabase stack, its edge functions, and the **SvelteKit web app**
(`svelte_dream_book`) locally.

> Looking for the project overview? See [README.md](README.md). For the legacy
> Flutter app setup, see [DEVELOPMENT_LEGACY.md](DEVELOPMENT_LEGACY.md).

## Contents

- [Prerequisites](#prerequisites)
- [Secret files you need](#secret-files-you-need)
- [Running Supabase (database, auth, storage, Studio)](#running-supabase-database-auth-storage-studio)
- [Running the edge functions](#running-the-edge-functions)
- [Seeding the Midjourney key in Vault](#seeding-the-midjourney-key-in-vault)
- [Local auth emails (OTP / magic links)](#local-auth-emails-otp--magic-links)
- [Running the Svelte web app](#running-the-svelte-web-app)
- [Troubleshooting](#troubleshooting)

---

## Prerequisites

- [Docker](https://docs.docker.com/get-docker/) running (the local stack runs in containers).
- [Supabase CLI](https://supabase.com/docs/guides/local-development) — `brew install supabase/tap/supabase`.
- [Deno 2](https://deno.com/) — `brew install deno` (only for running edge functions or the dev email hook outside the CLI's bundled runtime).
- [Node + pnpm](https://pnpm.io/) — for the Svelte web app.

All `supabase` commands run from the repo root (the `supabase/` directory is auto-detected).

---

## Secret files you need

Everything secret is **git-ignored** — create these by hand. Ask a teammate for the current values.

| File | Purpose | Loaded by |
| --- | --- | --- |
| `supabase/functions/.env` | Secrets injected into edge functions (Deepgram, Anthropic, Sentry; Midjourney lives in Vault) | `supabase start` auto-loads it |
| `svelte_dream_book/.env` | Supabase URL + anon key for the web app | SvelteKit (Vite) |

### `supabase/functions/.env`

```bash
# Deepgram TTS (used by generate-audio)
DEEPGRAM_KEY=<deepgram api key>

# Anthropic / Claude (used by compose-story to split a transcript into pages)
ANTHROPIC_API_KEY=<anthropic api key>   # from https://console.anthropic.com/settings/keys

# Sentry for edge functions (OPTIONAL — unset = full no-op). Use a Sentry "Deno"
# project DSN. Only SENTRY_DSN is required; the rest are optional tuning.
SENTRY_DSN=<sentry deno dsn>
SENTRY_ENVIRONMENT=production
SENTRY_RELEASE=<git sha>
SENTRY_TRACES_SAMPLE_RATE=1.0
```

Notes:

- The Midjourney token set is **not** here — it lives in Supabase Vault (secret
  `midjourney_oauth`), because Midjourney rotates refresh tokens on every refresh
  and the function persists the rotated set there. Seed it separately — see
  [below](#seeding-the-midjourney-key-in-vault).
- `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` are injected
  into functions automatically by the local stack. You only set those when
  deploying to a hosted project.

### `svelte_dream_book/.env`

Copy `svelte_dream_book/.env.example` (already filled with the local stack
values) to `.env`. The anon key is safe in the browser — it only grants
RLS-scoped access.

`
[truncated — 6782 more characters]
```

### DEVELOPMENT_LEGACY.md

```markdown
# Local development

How to run the Supabase stack and its edge functions locally for this monorepo.

> Looking for the project overview? See [README.md](README.md). This file is
> only about getting a local backend running.

## Contents

- [Local development](#local-development)
  - [Contents](#contents)
  - [Prerequisites](#prerequisites)
  - [Secret files you need](#secret-files-you-need)
    - [`supabase/functions/.env`](#supabasefunctionsenv)
    - [`.env` (repo root)](#env-repo-root)
  - [Running Supabase (database, auth, storage, Studio)](#running-supabase-database-auth-storage-studio)
  - [Running the edge functions](#running-the-edge-functions)
  - [Seeding the Midjourney key in Vault](#seeding-the-midjourney-key-in-vault)
  - [Local auth emails (OTP / magic links)](#local-auth-emails-otp--magic-links)
  - [Pointing the Flutter app at local Supabase](#pointing-the-flutter-app-at-local-supabase)
  - [Troubleshooting](#troubleshooting)

---

## Prerequisites

- [Docker](https://docs.docker.com/get-docker/) running (the local stack runs in containers).
- [Supabase CLI](https://supabase.com/docs/guides/local-development) — `brew install supabase/tap/supabase`.
- [Deno 2](https://deno.com/) - `brew install deno` (only needed if you run the edge functions or the
  dev email hook outside the CLI's bundled runtime).

All `supabase` commands below are run from the repo root (the `supabase/`
directory is auto-detected).

---

## Secret files you need

Everything secret is **git-ignored** — you must create these by hand. Ask a
teammate for the current values (they are not in the repo).

| File | Purpose | Loaded by | Git-ignored in |
| --- | --- | --- | --- |
| `supabase/functions/.env` | Secrets injected into edge functions (Deepgram; Midjourney lives in Vault) | `supabase start` auto-loads it | `supabase/.gitignore` |
| `dream_book/.env` | Frontend's copy of the Midjourney seed tokens (only if you run the Flutter app against the MCP directly) | Flutter app | `dream_book/.gitignore` |

### `supabase/functions/.env`

This is the important one for the backend. Create it with:

```bash
# Deepgram TTS (used by generate-audio)
DEEPGRAM_KEY=<deepgram api key>

# Anthropic / Claude (used by compose-story to split a transcript into pages)
ANTHROPIC_API_KEY=<anthropic api key>   # from https://console.anthropic.com/settings/keys

# Sentry for the edge functions (OPTIONAL — unset = full no-op, nothing sent).
# Use a Sentry "Deno" project DSN. Every function is wrapped by serveWithSentry
# in _shared/sentry.ts, which gives each request its own scope, structured logs,
# performance spans + PII scrubbing, reports 5xx/unexpected errors, and flushes
# before the isolate freezes. Only SENTRY_DSN is required to turn it on; the
# rest are optional tuning.
SENTRY_DSN=<sentry deno dsn>
SENTRY_ENVIRONMENT=production         # e.g. production / staging (default production)
SENTRY_RELEASE=<git sha>              # release/version string for grouping
SENTRY_TRACES_SAMPLE_
[truncated — 7599 more characters]
```

### svelte_dream_book/package.json

```
{
  "name": "svelte-dream-book",
  "private": true,
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
    "tunnel": "ngrok start --config ngrok.yml dreambook",
    "build": "vite build",
    "preview": "vite preview",
    "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
    "lint": "prettier --check . && eslint .",
    "format": "prettier --write .",
    "gen:types": "supabase gen types typescript --local > src/lib/database.types.ts"
  },
  "devDependencies": {
    "@eslint/compat": "^1.2.5",
    "@eslint/js": "^9.18.0",
    "@sveltejs/adapter-vercel": "^6.3.4",
    "@sveltejs/kit": "^2.66.0",
    "@sveltejs/vite-plugin-svelte": "^7.1.2",
    "@tailwindcss/vite": "^4.0.0",
    "eslint": "^9.18.0",
    "eslint-config-prettier": "^10.0.1",
    "eslint-plugin-svelte": "^3.0.0",
    "globals": "^16.0.0",
    "prettier": "^3.4.2",
    "prettier-plugin-svelte": "^3.3.3",
    "svelte": "^5.19.0",
    "svelte-check": "^4.1.0",
    "tailwindcss": "^4.0.0",
    "typescript": "^5.7.0",
    "typescript-eslint": "^8.20.0",
    "vite": "^8.0.16"
  },
  "dependencies": {
    "@sentry/sveltekit": "^10.59.0",
    "@supabase/supabase-js": "^2.47.0"
  },
  "pnpm": {
    "onlyBuiltDependencies": [
      "esbuild"
    ]
  },
  "packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820"
}

```

### tools/dev-email-auth-hook/main.ts

```typescript
/*
 * Development-only auth email hook.
 * Captures the Supabase "send email" webhook payload and prints the OTP code to
 * the console, so you never need a real mailbox while developing locally.
 *
 * Run:  deno run --allow-net tools/dev-email-auth-hook/main.ts
 * Wired up via [auth.hook.send_email] in supabase/config.toml.
 * Output also appears in the supabase_edge_runtime_* docker container logs.
 */

import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { Webhook } from "https://esm.sh/standardwebhooks@1.0.0";

const port = 4000;

Deno.serve({ port }, async (req) => {
  if (req.method !== "POST") {
    return new Response("not allowed", { status: 400 });
  }

  const payload = await req.text();
  const headers = Object.fromEntries(req.headers);

  // Must match the secret in supabase/config.toml -> [auth.hook.send_email].
  const wh = new Webhook(
    "VmZGNUpZRlZzbGFDS08zc3pUOVlaVjBraGRDZFJ5ZVBzN09QWkVjMjFGOD0K",
  );

  const { user, email_data } = wh.verify(payload, headers) as {
    user: {
      email: string;
    };
    email_data: {
      token: string;
      token_hash: string;
      redirect_to: string;
      email_action_type: string;
      site_url: string;
      token_new: string;
      token_hash_new: string;
    };
  };

  console.log({ email_data, user });
  console.log(`EMAIL OTP for ${user.email}: ${email_data.token}`);

  return new Response(JSON.stringify({}), {
    status: 200,
    headers: new Headers({ "Content-Type": "application/json" }),
  });
});

```

### supabase/functions/hello-world/index.ts

```typescript
// Template edge function — copy this as the starting point for new functions.
// serveWithSentry (../_shared/sentry.ts) handles CORS, a per-request Sentry
// scope, error→Response shaping, and flushing; the callback is just your logic.
// Verify locally with:
//   supabase functions serve hello-world
//   curl -i --request POST http://localhost:54321/functions/v1/hello-world \
//     --header "Content-Type: application/json" --data '{"name":"world"}'
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { jsonResponse, serveWithSentry } from "../_shared/sentry.ts";

serveWithSentry("hello-world", async (req) => {
  const { name } = await req.json().catch(() => ({ name: "world" }));
  return jsonResponse({ message: `Hello ${name ?? "world"}!` });
});

```

### supabase/functions/voice-agent-token/index.ts

```typescript
// POST /functions/v1/voice-agent-token
// Body: none.
// Verifies the caller is a signed-in user, then mints a short-lived Deepgram
// token the browser uses to open the Voice Agent WebSocket. The long-lived
// DEEPGRAM_KEY never leaves the server. Returns { token, expires_in }.
//
// CORS, per-request Sentry scope, and error→Response shaping all live in
// serveWithSentry (../_shared/sentry.ts) — this file is just the business logic.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { getAuthenticatedUser } from "../_shared/auth.ts";
import { grantVoiceToken } from "../_shared/deepgram.ts";
import { breadcrumb, jsonResponse, serveWithSentry, setUser } from "../_shared/sentry.ts";

serveWithSentry("voice-agent-token", async (req) => {
  const { user } = await getAuthenticatedUser(req);
  setUser(user.id);
  breadcrumb("voice", "token request", { user_id: user.id });

  const { token, expiresIn } = await grantVoiceToken();

  return jsonResponse({ token, expires_in: expiresIn });
});

```

### supabase/functions/transcribe/index.ts

```typescript
// POST /functions/v1/transcribe
// Body: { audio_base64, mime? }
// Transcribes a recorded audio clip via Deepgram STT and returns { transcript }.
//
// The Deepgram key stays server-side: browsers can't be trusted with a paid API
// key, so the web "Tell a Story" screen records audio and hands the bytes here
// (base64 in JSON) instead of calling Deepgram directly like the Flutter app.
//
// CORS, per-request Sentry scope, and error→Response shaping all live in
// serveWithSentry (../_shared/sentry.ts) — this file is just the business logic.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { ErrorResponse, getAuthenticatedUser } from "../_shared/auth.ts";
import { transcribe } from "../_shared/deepgram.ts";
import { breadcrumb, jsonResponse, serveWithSentry, setUser } from "../_shared/sentry.ts";

// Guard against an oversized payload pinning the isolate. ~8 MB of base64 is
// many minutes of Opus audio — far more than a single spoken story needs.
const MAX_BASE64_CHARS = 8_000_000;

serveWithSentry("transcribe", async (req) => {
  const { user } = await getAuthenticatedUser(req);
  setUser(user.id);

  const body = await req.json().catch(() => ({})) as {
    audio_base64?: string;
    mime?: string;
  };

  const b64 = body.audio_base64 ?? "";
  if (!b64) {
    throw { error: "audio_base64 is required", status: 400 } as ErrorResponse;
  }
  if (b64.length > MAX_BASE64_CHARS) {
    throw { error: "Recording is too long; please keep it under a few minutes.", status: 413 } as ErrorResponse;
  }

  let audio: Uint8Array;
  try {
    audio = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
  } catch {
    throw { error: "audio_base64 is not valid base64", status: 400 } as ErrorResponse;
  }
  breadcrumb("transcribe", "request", { bytes: audio.byteLength });

  const transcript = await transcribe(audio, body.mime ?? "audio/webm");
  return jsonResponse({ transcript });
});

```

### supabase/functions/generate-audio/index.ts

```typescript
// POST /functions/v1/generate-audio
// Body: { page_id, text? }
// Verifies page ownership, synthesizes speech via Deepgram, uploads the MP3 to
// the `audio` bucket, and stamps page.audio_url. Returns { audio_url }.
//
// CORS, per-request Sentry scope, and error→Response shaping all live in
// serveWithSentry (../_shared/sentry.ts) — this file is just the business logic.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { ErrorResponse, getAuthenticatedUser, serviceClient } from "../_shared/auth.ts";
import { speak } from "../_shared/deepgram.ts";
import { publicUrl } from "../_shared/storage.ts";
import { breadcrumb, jsonResponse, serveWithSentry, setTags, setUser } from "../_shared/sentry.ts";

const BUCKET = "audio";

serveWithSentry("generate-audio", async (req) => {
  const { user } = await getAuthenticatedUser(req);
  setUser(user.id);

  const body = await req.json().catch(() => ({})) as {
    page_id?: string;
    text?: string;
  };
  const pageId = body.page_id;
  if (!pageId) {
    throw { error: "page_id is required", status: 400 } as ErrorResponse;
  }
  setTags({ page_id: pageId });
  breadcrumb("audio", "request", { page_id: pageId });

  const admin = serviceClient();

  const { data: page, error: pageError } = await admin
    .from("page")
    .select("id, story_id, text, story:story_id(author_id)")
    .eq("id", pageId)
    .single();

  if (pageError || !page) {
    throw { error: "Page not found", status: 404 } as ErrorResponse;
  }

  const story = Array.isArray(page.story) ? page.story[0] : page.story;
  if (!story || story.author_id !== user.id) {
    throw { error: "Forbidden: you do not own this story", status: 403 } as ErrorResponse;
  }

  const storyId = page.story_id;
  setTags({ story_id: storyId });
  const text = (body.text ?? page.text ?? "").trim();
  if (!text) {
    throw { error: "No text to synthesize", status: 400 } as ErrorResponse;
  }

  const mp3 = await speak(text);

  const path = `${user.id}/${storyId}/${pageId}.mp3`;
  const { error: uploadError } = await admin.storage
    .from(BUCKET)
    .upload(path, mp3, { contentType: "audio/mpeg", upsert: true });
  if (uploadError) {
    throw { error: `Upload failed: ${uploadError.message}`, status: 500 } as ErrorResponse;
  }

  const audioUrl = publicUrl(BUCKET, path);

  const { error: updateError } = await admin
    .from("page")
    .update({ audio_url: audioUrl })
    .eq("id", pageId);
  if (updateError) {
    throw { error: `Update failed: ${updateError.message}`, status: 500 } as ErrorResponse;
  }

  return jsonResponse({ audio_url: audioUrl });
});

```

### supabase/functions/generate-texture/index.ts

```typescript
// POST /functions/v1/generate-texture
// Body: { story_id, prompt }
// Verifies story ownership, calls Midjourney, and stamps story.cover_texture
// (the book-cover art) with the Midjourney CDN URL.
// Returns { texture_url, job_id, images }.
//
// We do NOT re-host the image in Supabase storage: cdn.midjourney.com is behind
// Cloudflare, which blocks server-side (non-browser TLS fingerprint) downloads
// with a 403. The app loads the CDN URL directly (Dart's http stack is allowed).
//
// CORS, per-request Sentry scope, and error→Response shaping all live in
// serveWithSentry (../_shared/sentry.ts) — this file is just the business logic.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { ErrorResponse, getAuthenticatedUser, serviceClient } from "../_shared/auth.ts";
import { generateImage } from "../_shared/midjourney.ts";
import { breadcrumb, jsonResponse, serveWithSentry, setTags, setUser } from "../_shared/sentry.ts";

serveWithSentry("generate-texture", async (req) => {
  const { user } = await getAuthenticatedUser(req);
  setUser(user.id);

  const body = await req.json().catch(() => ({})) as {
    story_id?: string;
    prompt?: string;
  };
  const storyId = body.story_id;
  const prompt = body.prompt;
  if (!storyId || !prompt) {
    throw { error: "story_id and prompt are required", status: 400 } as ErrorResponse;
  }
  setTags({ story_id: storyId });
  breadcrumb("texture", "request", { story_id: storyId, prompt_len: prompt.length });

  const admin = serviceClient();

  // Fetch the story to verify ownership.
  const { data: story, error: storyError } = await admin
    .from("story")
    .select("id, author_id")
    .eq("id", storyId)
    .single();

  if (storyError || !story) {
    throw { error: "Story not found", status: 404 } as ErrorResponse;
  }

  if (story.author_id !== user.id) {
    throw { error: "Forbidden: you do not own this story", status: 403 } as ErrorResponse;
  }

  // Generate the image. We store the Midjourney CDN URL of the first grid
  // image directly (no re-hosting — see the header note).
  const { jobId, images } = await generateImage(prompt);
  if (images.length === 0) {
    throw { error: "Midjourney returned no images", status: 502 } as ErrorResponse;
  }

  // MJ returns a 2x2 grid (4 takes on the prompt); we auto-pick #1. The full
  // set is still returned to the caller in `images` below.
  // TODO(optional): let the user pick from the grid instead of auto-#1 —
  // show `images` in the UI, then stamp the chosen URL. Backend already
  // returns the array; only the Flutter picker is missing. MJ variance is
  // high, so #1 is often not the best of the 4.
  const textureUrl = images[0];
  console.log(`Generated cover texture for story ${storyId}: ${textureUrl}`);

  const { error: updateError } = await admin
    .from("story")
    .update({ cover_texture: textureUrl })
    .eq("id", storyId);
  if (updateError) {
    throw { error: `Update failed: ${updateError.message}`, status: 500 } as ErrorResponse;
  }

  return jsonResponse({
    texture_url: textureUrl,
    job_id: jobId,
    images,
  });
});

```

### supabase/functions/generate-illustration/index.ts

```typescript
// POST /functions/v1/generate-illustration
// Body: { page_id, prompt }
// Verifies page ownership, calls Midjourney, and stamps page.illustration_url
// with the Midjourney CDN URL. Returns { illustration_url, job_id, images }.
//
// We do NOT re-host the image in Supabase storage: cdn.midjourney.com is behind
// Cloudflare, which blocks server-side (non-browser TLS fingerprint) downloads
// with a 403. The app loads the CDN URL directly (Dart's http stack is allowed).
//
// CORS, per-request Sentry scope, and error→Response shaping all live in
// serveWithSentry (../_shared/sentry.ts) — this file is just the business logic.
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { ErrorResponse, getAuthenticatedUser, serviceClient } from "../_shared/auth.ts";
import { generateImage } from "../_shared/midjourney.ts";
import { breadcrumb, jsonResponse, serveWithSentry, setTags, setUser } from "../_shared/sentry.ts";

serveWithSentry("generate-illustration", async (req) => {
  const { user } = await getAuthenticatedUser(req);
  setUser(user.id);

  const body = await req.json().catch(() => ({})) as {
    page_id?: string;
    prompt?: string;
  };
  const pageId = body.page_id;
  const prompt = body.prompt;
  if (!pageId || !prompt) {
    throw { error: "page_id and prompt are required", status: 400 } as ErrorResponse;
  }
  setTags({ page_id: pageId });
  breadcrumb("illustration", "request", { page_id: pageId, prompt_len: prompt.length });

  const admin = serviceClient();

  // Fetch the page joined to its story to verify ownership.
  const { data: page, error: pageError } = await admin
    .from("page")
    .select("id, story_id, story:story_id(author_id)")
    .eq("id", pageId)
    .single();

  if (pageError || !page) {
    throw { error: "Page not found", status: 404 } as ErrorResponse;
  }

  // story:story_id(...) is returned as an object (to-one); be defensive.
  const story = Array.isArray(page.story) ? page.story[0] : page.story;
  if (!story || story.author_id !== user.id) {
    throw { error: "Forbidden: you do not own this story", status: 403 } as ErrorResponse;
  }
  setTags({ story_id: page.story_id });

  // Generate the image. We store the Midjourney CDN URL of the first grid
  // image directly (no re-hosting — see the header note).
  const { jobId, images } = await generateImage(prompt);
  if (images.length === 0) {
    throw { error: "Midjourney returned no images", status: 502 } as ErrorResponse;
  }

  // MJ returns a 2x2 grid (4 takes on the prompt); we auto-pick #1. The full
  // set is still returned to the caller in `images` below.
  // TODO(optional): let the user pick from the grid instead of auto-#1 —
  // show `images` in the UI, then stamp the chosen URL. Backend already
  // returns the array; only the Flutter picker is missing. MJ variance is
  // high, so #1 is often not the best of the 4.
  const illustrationUrl = images[0];
  console.log(`Generated illustration for page ${pageId}: ${illustrationUrl}`);

  const { error: updateError } = await admin
    .from("page")
    .update({ illustration_url: illustrationUrl })
    .eq("id", pageId);
  if (updateError) {
    throw { error: `Update failed: ${updateError.message}`, status: 500 } as ErrorResponse;
  }

  return jsonResponse({
    illustration_url: illustrationUrl,
    job_id: jobId,
    images,
  });
});

```

[178 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]