# Project export: Vibe City

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: Vibe City is an interactive story game that teaches prompt engineering. Guide Percy the pigeon through the city by learning how to ask AI assistants exactly what you need.
- Devpost: https://devpost.com/software/vibe-city
- GitHub: https://github.com/aidanhenriquess/AI-Hackathon.git
- Demo: https://ai-hackathon-three-fawn.vercel.app/
- Video: https://www.youtube.com/embed/f2FERMyPyGA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 5 GitHub contributor(s) — Claude Sonnet 4.6 (33 commits), Aidan Henriques (31 commits), kavya-sasikumar (11 commits), Mihir (6 commits), Aidan Henriques (1 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.


## Detected evidence (automated analysis)

Indexed codebase: 202 recognized source files, 1285 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Vercel AI SDK (technology) — detected in the code
- JavaScript (language) — claimed on Devpost, not found in the code
- PostgreSQL (technology) — claimed on Devpost, not found in the code
- Vercel (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

## Codebase structure (from repository index)

### Files (120 of 249)

```
.claude/skills/create-challenge/reference.md
.claude/skills/create-challenge/SKILL.md
.claude/skills/create-story/reference.md
.claude/skills/create-story/SKILL.md
.env.local.example
.gitignore
.npmrc
.playwright-mcp/console-2026-06-20T21-27-41-158Z.log
.playwright-mcp/console-2026-06-20T22-14-46-576Z.log
.playwright-mcp/console-2026-06-20T22-18-49-060Z.log
.playwright-mcp/console-2026-06-20T22-24-20-413Z.log
.playwright-mcp/console-2026-06-20T22-28-46-977Z.log
.playwright-mcp/console-2026-06-20T22-29-30-532Z.log
.playwright-mcp/console-2026-06-20T22-31-18-437Z.log
.playwright-mcp/console-2026-06-20T22-33-18-056Z.log
.playwright-mcp/console-2026-06-20T22-40-08-613Z.log
.playwright-mcp/console-2026-06-20T23-40-20-688Z.log
.playwright-mcp/console-2026-06-20T23-40-41-638Z.log
.playwright-mcp/console-2026-06-20T23-40-57-057Z.log
.playwright-mcp/console-2026-06-21T04-28-48-167Z.log
.playwright-mcp/console-2026-06-21T04-29-04-877Z.log
.playwright-mcp/console-2026-06-21T05-09-18-242Z.log
.playwright-mcp/console-2026-06-21T05-09-47-301Z.log
.playwright-mcp/console-2026-06-21T05-09-54-387Z.log
.playwright-mcp/console-2026-06-21T05-11-09-375Z.log
.playwright-mcp/console-2026-06-21T05-25-14-834Z.log
.playwright-mcp/console-2026-06-21T05-25-35-787Z.log
.playwright-mcp/console-2026-06-21T05-25-53-090Z.log
.playwright-mcp/page-2026-06-20T21-27-41-419Z.yml
.playwright-mcp/page-2026-06-20T21-27-52-374Z.yml
.playwright-mcp/page-2026-06-20T21-28-14-581Z.yml
.playwright-mcp/page-2026-06-20T21-28-37-326Z.yml
.playwright-mcp/page-2026-06-20T21-28-56-855Z.yml
.playwright-mcp/page-2026-06-20T21-30-05-670Z.yml
.playwright-mcp/page-2026-06-20T21-43-38-414Z.yml
.playwright-mcp/page-2026-06-20T21-44-10-572Z.yml
.playwright-mcp/page-2026-06-20T21-44-38-218Z.yml
.playwright-mcp/page-2026-06-20T21-49-02-713Z.yml
.playwright-mcp/page-2026-06-20T21-49-26-941Z.yml
.playwright-mcp/page-2026-06-20T21-49-37-852Z.yml
.playwright-mcp/page-2026-06-20T21-50-00-106Z.yml
.playwright-mcp/page-2026-06-20T21-50-16-422Z.yml
.playwright-mcp/page-2026-06-20T21-50-21-362Z.yml
.playwright-mcp/page-2026-06-20T22-01-34-788Z.yml
.playwright-mcp/page-2026-06-20T22-13-34-908Z.yml
.playwright-mcp/page-2026-06-20T22-14-46-893Z.yml
.playwright-mcp/page-2026-06-20T22-18-49-353Z.yml
.playwright-mcp/page-2026-06-20T22-24-21-654Z.yml
.playwright-mcp/page-2026-06-20T22-28-47-576Z.yml
.playwright-mcp/page-2026-06-20T22-29-31-837Z.yml
.playwright-mcp/page-2026-06-20T22-31-18-602Z.yml
.playwright-mcp/page-2026-06-20T22-31-27-444Z.yml
.playwright-mcp/page-2026-06-20T22-31-40-278Z.yml
.playwright-mcp/page-2026-06-20T22-31-53-206Z.yml
.playwright-mcp/page-2026-06-20T22-32-44-324Z.yml
.playwright-mcp/page-2026-06-20T22-32-53-275Z.yml
.playwright-mcp/page-2026-06-20T22-33-18-358Z.yml
.playwright-mcp/page-2026-06-20T22-33-44-488Z.yml
.playwright-mcp/page-2026-06-20T22-33-58-042Z.yml
.playwright-mcp/page-2026-06-20T22-34-16-545Z.yml
.playwright-mcp/page-2026-06-20T22-40-09-384Z.yml
.playwright-mcp/page-2026-06-20T22-40-33-101Z.yml
.playwright-mcp/page-2026-06-20T22-40-49-559Z.yml
.playwright-mcp/page-2026-06-20T22-41-22-202Z.yml
.playwright-mcp/page-2026-06-20T22-41-34-487Z.yml
.playwright-mcp/page-2026-06-20T22-42-12-089Z.yml
.playwright-mcp/page-2026-06-20T22-42-25-104Z.yml
.playwright-mcp/page-2026-06-20T22-42-56-346Z.yml
.playwright-mcp/page-2026-06-20T22-43-04-982Z.yml
.playwright-mcp/page-2026-06-20T22-43-14-591Z.yml
.playwright-mcp/page-2026-06-20T23-40-20-804Z.yml
.playwright-mcp/page-2026-06-20T23-40-41-979Z.yml
.playwright-mcp/page-2026-06-20T23-40-57-514Z.yml
.playwright-mcp/page-2026-06-21T04-28-48-994Z.yml
.playwright-mcp/page-2026-06-21T04-29-05-441Z.yml
.playwright-mcp/page-2026-06-21T04-29-16-602Z.yml
.playwright-mcp/page-2026-06-21T04-29-47-482Z.yml
.playwright-mcp/page-2026-06-21T04-30-01-484Z.yml
.playwright-mcp/page-2026-06-21T04-30-40-049Z.yml
.playwright-mcp/page-2026-06-21T04-30-54-636Z.yml
.playwright-mcp/page-2026-06-21T05-09-18-468Z.yml
.playwright-mcp/page-2026-06-21T05-09-47-437Z.yml
.playwright-mcp/page-2026-06-21T05-09-54-563Z.yml
.playwright-mcp/page-2026-06-21T05-11-09-719Z.yml
.playwright-mcp/page-2026-06-21T05-25-15-101Z.yml
.playwright-mcp/page-2026-06-21T05-25-36-022Z.yml
.playwright-mcp/page-2026-06-21T05-25-53-764Z.yml
AGENTS.md
ANSWER_KEY.md
app/api/chat/route.ts
app/api/config/route.ts
app/api/game/start/route.ts
app/api/game/submit/route.ts
app/api/hint/route.ts
app/api/leaderboard/route.ts
app/api/scenes/route.ts
app/api/scores/route.ts
app/api/story/chat/route.ts
app/api/story/route.ts
app/api/submit/route.ts
app/auth/page.tsx
app/game/GameClient.tsx
app/game/page.tsx
app/globals.css
app/layout.tsx
app/page.tsx
app/preview/page.tsx
app/select/page.tsx
app/story/[slug]/page.tsx
app/story/page.tsx
app/story/StoryClient.tsx
bun.lock
challenges.json
CLAUDE.md
components/ANIM_CSS.css
components/ChatPanel.tsx
components/CodeBlock.tsx
components/crayon.tsx
components/CrayonFilter.tsx
components/Doodles.tsx
[129 more files omitted for size]
```

### Dependencies

- package.json: @ai-sdk/anthropic@^0.0.56, @supabase/ssr@^0.12.0, @supabase/supabase-js@^2.108.2, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, @types/react-syntax-highlighter@^15.5.13, ai@^3.4.33, dotenv@^17.4.2, eslint@^9, eslint-config-next@16.2.9, framer-motion@^12.40.0, next@16.2.9, openai@^6.44.0, react@19.2.4, react-dom@19.2.4, react-markdown@^10.1.0, react-syntax-highlighter@^16.1.1, remark-gfm@^4.0.1, tailwindcss@^4, tsx@^4.22.4, typescript@^5.9.3, zod@^3.23.8

### Recent commits (newest first)

- Story leaderboard + StoryComplete board, deck, Percy P1 answer key
- feat: add character portraits to story select page + install react-markdown
- remove floating grey rectangle from Scene 2 (Breakfast Time)
- new animals and fix chatbot window
- Crayon UI/animation polish + split Percy's Day into two parts
- Fix hints in story mode + story/UI work
- Revert interactive-animation changes (3bebec6 + 5161859)
- fix: tone down animations — remove speech bubbles, reduce emoji to stars only
- Merge remote-tracking branch 'origin/main'
- Add multipart story mode: scene-mapped exact-output challenges + playback
- feat: interactive animations — sparkle bursts, confetti, Percy speech bubbles
- Merge remote-tracking branch 'origin/main'
- Checkpoint: multipart-story architecture, eval harness, create-challenge skill
- Merge branch 'main' of https://github.com/aidanhenriquess/AI-Hackathon
- Merge: accept Next.js migration, keep components library
- feat: scene animations, 9 scenes, preview page, remove Supabase
- Add graphic components library and update game art
- Rename "Mission Brief" label to "Problem"
- Redesign Percy and title pigeon with improved crayon filter
- Remove "your story begins when you start prompting" placeholder caption

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

### Hi.md

```markdown
Hi
```

### CLAUDE.md

```markdown
@AGENTS.md

```

### package.json

```
{
  "name": "prompt-trainer",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint",
    "seed": "npx tsx scripts/seed.ts",
    "test": "node --import tsx --test tests/*.test.ts",
    "test:unit": "node --import tsx --test tests/similarity.test.ts tests/multipart.test.ts",
    "test:judge": "node --import tsx --test tests/judge.test.ts",
    "test:embeddings": "node --import tsx --test tests/embeddings.test.ts"
  },
  "dependencies": {
    "@ai-sdk/anthropic": "^0.0.56",
    "@supabase/ssr": "^0.12.0",
    "@supabase/supabase-js": "^2.108.2",
    "ai": "^3.4.33",
    "framer-motion": "^12.40.0",
    "next": "16.2.9",
    "openai": "^6.44.0",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "react-markdown": "^10.1.0",
    "react-syntax-highlighter": "^16.1.1",
    "remark-gfm": "^4.0.1",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "@types/react-syntax-highlighter": "^15.5.13",
    "dotenv": "^17.4.2",
    "eslint": "^9",
    "eslint-config-next": "16.2.9",
    "tailwindcss": "^4",
    "tsx": "^4.22.4",
    "typescript": "^5.9.3"
  }
}

```

### app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist_Mono, Patrick_Hand } from "next/font/google";
import "./globals.css";
import { CrayonFilter } from "@/components/CrayonFilter";

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

// Patrick Hand is the hand-drawn "crayon" display face used across the whole app.
// It is non-variable, so a weight must be specified.
const patrickHand = Patrick_Hand({
  weight: "400",
  variable: "--font-patrick-hand",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Vibe City",
  description:
    "Learn prompt engineering by guiding Percy the pigeon through the city — race the clock to coax the AI to the target answer.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${patrickHand.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-full flex flex-col bg-paper text-ink font-hand">
        {/* Hand-drawn filter shared by every crayon SVG in the app. */}
        <CrayonFilter />
        {children}
      </body>
    </html>
  );
}

```

### app/page.tsx

```typescript
'use client';

import { motion } from 'framer-motion';
import { ParkBackdrop } from '@/components/ParkBackdrop';
import { Percy } from '@/components/Percy';
import { FallingLeaves } from '@/components/Doodles';
import { CrayonButton, CrayonUnderline, CrayonShadow, IconPlay } from '@/components/crayon';

export default function TitlePage() {
  return (
    <main className="relative min-h-screen overflow-hidden bg-sky text-ink">
      {/* Storybook park — sky, grass, tree, bench, acorns (north-star scene) */}
      <ParkBackdrop />
      <FallingLeaves top="-6%" count={3} />

      <div className="relative z-10 flex min-h-screen flex-col items-center justify-center gap-1 px-6 pb-[8vh]">
        {/* Title — a drawn-by-hand sign: wobble filter + a slight tilt */}
        <motion.h1
          initial={{ scale: 0.7, rotate: -5, opacity: 0 }}
          animate={{ scale: 1, rotate: -1.5, opacity: 1 }}
          transition={{ type: 'spring', stiffness: 240, damping: 14 }}
          className="m-0 text-center font-hand text-[64px] leading-none tracking-wide text-ink md:text-[76px]"
          style={{ filter: 'url(#crayon)' }}
        >
          Vibe City
        </motion.h1>
        <CrayonUnderline width={250} color="var(--gold)" draw style={{ marginTop: 2 }} />

        <motion.p
          initial={{ opacity: 0, y: 6 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ delay: 0.35, duration: 0.4 }}
          className="mt-4 max-w-[22rem] text-center text-[19px] leading-snug text-ink-dim"
        >
          Help Percy the pigeon talk his way across the city.
        </motion.p>

        {/* Percy, grounded on a crayon shadow, breathing */}
        <motion.div
          initial={{ opacity: 0, y: 14 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ delay: 0.2, type: 'spring', stiffness: 200, damping: 16 }}
          className="relative mt-3 flex flex-col items-center"
        >
          <div className="vc-bob">
            <Percy size={180} pose="awake" />
          </div>
          <CrayonShadow width={140} style={{ marginTop: -6 }} />
        </motion.div>

        {/* Play CTA — a tactile gold paper button */}
        <motion.div
          initial={{ opacity: 0, scale: 0.85 }}
          animate={{ opacity: 1, scale: 1 }}
          transition={{ delay: 0.5, type: 'spring', stiffness: 300, damping: 16 }}
          className="mt-5"
        >
          <CrayonButton href="/select" tone="gold" aria-label="Play" innerClassName="px-7 py-2.5 gap-2.5 text-[22px]">
            <IconPlay size={17} />
            Play
          </CrayonButton>
        </motion.div>
      </div>
    </main>
  );
}

```

### lib/supabase/server.ts

```typescript
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export const createClient = async () => {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (cookiesToSet) => {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // setAll called from a Server Component — safe to ignore
          }
        },
      },
    }
  );
};

```

### app/game/page.tsx

```typescript
import scenes from '@/scenes.json';
import { GameClient } from './GameClient';

export default async function GamePage({ searchParams }: { searchParams: Promise<{ id?: string }> }) {
  const { id } = await searchParams;
  const scene = id ? scenes.find((s) => String(s.id) === id) : scenes[0];

  if (!scene) {
    return (
      <div className="min-h-screen bg-paper text-ink-dim flex items-center justify-center font-mono text-sm">
        No scene found.
      </div>
    );
  }

  const challenge = {
    id: String(scene.id),
    title: scene.title,
    problem_statement: scene.description,
    win_threshold: 0.7,
    sceneId: scene.id,
  };

  return <GameClient challenge={challenge} />;
}

```

### app/story/page.tsx

```typescript
import { StoryClient } from './StoryClient';

/**
 * STORY MODE entry point.
 *
 * Loads the multipart story (file-driven, stateless): the client fetches the
 * problem manifest from GET /api/story and sequences through the scenes, posting
 * each turn to POST /api/story/chat with the current `problem_order`. Solving a
 * mini-problem completes its scene's animation and advances to the next.
 *
 * Additive: the legacy /select scene picker, /game single-scene flow, /api/chat,
 * and /api/game/* are all untouched.
 *
 * `/story` is the built-in Percy story; `/story/<slug>` plays any other
 * registered story (see app/story/[slug]/page.tsx + lib/stories.server.ts).
 */
export default function StoryPage() {
  return <StoryClient slug="percy" />;
}

```

### app/auth/page.tsx

```typescript
'use client';

import { Suspense, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';

function AuthForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const next = searchParams.get('next') ?? '/game';
  const [mode, setMode] = useState<'login' | 'signup'>('login');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    setLoading(true);
    const supabase = createClient();

    const { error } = mode === 'login'
      ? await supabase.auth.signInWithPassword({ email, password })
      : await supabase.auth.signUp({ email, password });

    setLoading(false);
    if (error) {
      setError(error.message);
    } else {
      router.push(next);
      router.refresh();
    }
  }

  return (
    <main className="min-h-screen bg-paper flex items-center justify-center px-4">
      <div className="w-full max-w-sm">
        <div className="flex items-center justify-center gap-3 mb-8">
          <svg viewBox="0 0 60 50" width="32" height="28" filter="url(#crayon)">
            <ellipse cx="30" cy="28" rx="18" ry="12" fill="none" stroke="#1A1A1A" strokeWidth="1.5"/>
            <circle cx="42" cy="20" r="9" fill="none" stroke="#1A1A1A" strokeWidth="1.5"/>
            <circle cx="46" cy="18" r="1.5" fill="#1A1A1A"/>
            <path d="M48 20 L52 19 L49 22 Z" fill="#1A1A1A"/>
          </svg>
          <span className="font-hand text-ink text-2xl">Vibe City</span>
        </div>
        <h2 className="font-hand text-ink-dim text-base text-center mb-6">
          {mode === 'login' ? 'log in to play' : 'create your account'}
        </h2>
        <form onSubmit={handleSubmit} className="flex flex-col gap-3">
          <input
            type="email"
            placeholder="email"
            value={email}
            onChange={e => setEmail(e.target.value)}
            required
            className="bg-ink-faint border border-line rounded-sm px-4 py-3 text-ink text-sm outline-none focus:border-ink placeholder:text-ink-dim font-hand"
          />
          <input
            type="password"
            placeholder="password"
            value={password}
            onChange={e => setPassword(e.target.value)}
            required
            className="bg-ink-faint border border-line rounded-sm px-4 py-3 text-ink text-sm outline-none focus:border-ink placeholder:text-ink-dim font-hand"
          />
          {error && <p className="text-red-700 text-sm font-hand">{error}</p>}
          <button
            type="submit"
            disabled={loading}
            className="bg-ink text-paper border border-ink rounded-sm py-3 text-sm font-hand transition-colors mt-1 hover:bg-paper hover:text-ink disabled:opacity-50"
          >
            {loading ? '…' : mode === 'login' ? 'Log In →' : 'Create Account →'}
          </button>
        </form>
        <p className="text-ink-dim text-sm text-center mt-6 font-hand">
          {mode === 'login' ? "no account?" : 'have an account?'}{' '}
          <button
            onClick={() => { setMode(mode === 'login' ? 'signup' : 'login'); setError(null); }}
            className="text-ink-dim hover:text-ink underline"
          >
            {mode === 'login' ? 'sign up' : 'log in'}
          </button>
        </p>
      </div>
    </main>
  );
}

export default function AuthPage() {
  return (
    <Suspense>
      <AuthForm />
    </Suspense>
  );
}

```

### app/preview/page.tsx

```typescript
'use client';

import { useEffect, useMemo, useState } from 'react';
import { StoryPanel } from '@/components/StoryPanel';
import { SCENE_SETS } from '@/components/sceneSets';
import type { SceneSet } from '@/components/sceneKit';

/** Built-in Percy scenes (rendered by StoryPanel's internal SceneView). */
const PERCY_SCENES = [
  { id: 1, title: 'Rise & Shine' },
  { id: 2, title: 'Breakfast Time' },
  { id: 3, title: 'Crossing Guard' },
  { id: 4, title: 'Up, Up & Away' },
  { id: 5, title: 'Lost Pigeon' },
  { id: 6, title: 'Home Sweet Ledge' },
  { id: 7, title: 'Subway Seeker' },
  { id: 8, title: 'Rain Day Spa' },
  { id: 9, title: 'Squirrel Rumble' },
];

interface StoryOption {
  slug: string;
  label: string;
  scenes: { id: number; title: string }[];
  set?: SceneSet;
  winEmoji?: string;
}

/** Percy (built-in) first, then every registered story's scene set. */
const STORY_OPTIONS: StoryOption[] = [
  { slug: 'percy', label: 'Percy', scenes: PERCY_SCENES, set: undefined },
  ...Object.entries(SCENE_SETS)
    .filter(([slug, v]) => slug !== 'percy' && !!v)
    .map(([slug, v]) => ({ slug, label: v!.name, scenes: v!.scenes, set: v!.set, winEmoji: v!.winEmoji })),
];

export default function PreviewPage() {
  const [storySlug, setStorySlug] = useState('percy');
  const [sceneId, setSceneId] = useState(1);
  const [progress, setProgress] = useState(0);

  // Deep-link support: /preview?story=<slug>&scene=<id>&progress=<0-100>. Lets the
  // create-story verification loop screenshot any scene/frame with one navigate.
  // One-time hydration from the URL on mount. Done in an effect (not a lazy state
  // initializer) so it runs client-only and avoids an SSR/hydration mismatch; the
  // setState calls here are intentional.
  /* eslint-disable react-hooks/set-state-in-effect */
  useEffect(() => {
    const p = new URLSearchParams(window.location.search);
    const qStory = p.get('story');
    const opt = qStory ? STORY_OPTIONS.find((s) => s.slug === qStory) : undefined;
    if (opt) setStorySlug(opt.slug);
    const scenes = (opt ?? STORY_OPTIONS[0]).scenes;
    const qScene = Number(p.get('scene'));
    if (Number.isFinite(qScene) && scenes.some((s) => s.id === qScene)) setSceneId(qScene);
    const qProg = Number(p.get('progress'));
    if (p.has('progress') && Number.isFinite(qProg)) setProgress(Math.max(0, Math.min(100, qProg)));
  }, []);
  /* eslint-enable react-hooks/set-state-in-effect */

  const story = useMemo(
    () => STORY_OPTIONS.find((s) => s.slug === storySlug) ?? STORY_OPTIONS[0],
    [storySlug]
  );

  const score = progress / 100;

  return (
    <div className="h-screen flex flex-col bg-paper text-ink overflow-hidden">
      {/* Controls bar */}
      <div className="shrink-0 flex flex-wrap items-center gap-4 px-5 py-3 border-b border-line bg-paper z-50">
        <span className="text-xs uppercase tracking-widest text-ink-dim">Animation Preview</span>

        {/* Story picker — only shown when more than one story is registered */}
        {STORY_OPTIONS.length > 1 && (
          <select
            value={storySlug}
            onChange={(e) => {
              setStorySlug(e.target.value);
              const next = STORY_OPTIONS.find((s) => s.slug === e.target.value);
              setSceneId(next?.scenes[0]?.id ?? 1);
              setProgress(0);
            }}
            className="text-[11px] border border-line rounded px-2 py-1 bg-paper text-ink"
          >
            {STORY_OPTIONS.map((s) => (
              <option key={s.slug} value={s.slug}>{s.label}</option>
            ))}
          </select>
        )}

        {/* Scene picker */}
        <div className="flex flex-wrap gap-1.5">
          {story.scenes.map((s) => (
            <button
              key={s.id}
              onClick={() => { setSceneId(s.id); setProgress(0); }}
              className="px-2.5 py-1 text-[11px] rounded border transition-colors"
              style={{
                background: sceneId === s.id ? '#1A1A1A' : 'transparent',
                color: sceneId === s.id ? '#FDFBF7' : '#1A1A1A',
                borderColor: sceneId === s.id ? '#1A1A1A' : '#d4cfc7',
              }}
            >
              {s.id}. {s.title}
            </button>
          ))}
        </div>

        {/* Progress slider */}
        <div className="flex items-center gap-2 ml-auto shrink-0">
          <span className="text-xs text-ink-dim w-8 tabular-nums">{progress}%</span>
          <input
            type="range"
            min={0}
            max={100}
            value={progress}
            onChange={(e) => setProgress(Number(e.target.value))}
            className="w-48 accent-ink"
          />
          <div className="flex gap-1">
            {[0, 33, 66, 100].map((v) => (
              <button
                key={v}
                onClick={() => setProgress(v)}
                className="px-1.5 py-0.5 text-[10px] border border-line rounded hover:border-ink transition-colors"
              >
                {v}%
              </button>
            ))}
          </div>
        </div>
      </div>

      {/* Animation panel */}
      <div className="flex-1 relative min-h-0">
        <StoryPanel
          key={`${storySlug}-${sceneId}`}
          score={score}
          winThreshold={1}
          slides={[]}
          sceneId={sceneId}
          sceneSet={story.set}
          winEmoji={story.winEmoji}
        />
      </div>
    </div>
  );
}

```

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