# Project export: EasyDiagram

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: Learning how to draw diagrams with ai
- Devpost: https://devpost.com/software/compressgram
- GitHub: https://github.com/vighanesh2/HackBerkley2026
- Video: https://www.youtube.com/embed/ngfm2XnuM-o?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Vighanesh (9 commits), Sri Ram Swaminathan (5 commits), Cursor (5 commits)

## Devpost submission (written by the team)

### What's next

for Compressgram Two frustrations collided into one project. First, AI course generators have a retention problem nobody is solving. The whole market races to generate courses faster — "courses in seconds," "11x faster content." But a course you forget in a week is a fast way to waste time. Speed became table stakes; learning got left behind. We kept coming back to the Feynman technique — learn by explaining simply and answering questions until the gaps close — a proven, retention-first method that's barely used in schools and almost absent from AI course tools. That became our product: an agent that builds courses designed to stick, grounded in the learner's own material. Second, grounding courses in real source material (via RAG) means feeding large amounts of retrieved text to an LLM on every generation — which is slow and expensive, and at scale, that cost is the business. When we saw The Token Company's compression challenge, the two problems clicked together: the bloated, retrieval-heavy context our product produces is exactly what compression is built to shrink. One build could serve both. 🧠 What we learned The biggest lesson was conceptual: token reduction and downstream quality are a pair, never a single number. Anyone can delete tokens — delete them all and you've "compressed" 100% and destroyed the output. The real bar, and the one The Token Company's challenge actually sets, is reduce tokens while preserving the quality of what the model produces. That reframed our entire benchmark. We also went deep on the prompt-compression literature and learned there's a clean taxonomy behind it: Selective-Context — the ancestor: score each unit by informativeness, drop the least informative. Simple, but one-directional and prone to dropping things that matter. LLMLingua — adds a budget controller (different compression budgets for different parts of the prompt) and iterative, dependency-aware compression. Up to ~20×\times × compression with little loss. LongLLMLingua — makes compression question-aware (keep what's relevant to the query) and reorders key content to fight the "lost in the middle" effect. Crucially, it showed compression can improve downstream performance, not just preserve it. LLMLingua-2 — reframes compression as a token classification task (preserve/discard), trained on GPT-4-distilled labels — fast and task-agnostic. SCOPE — a generative approach (rewrite/summarize rather than delete). The mechanism that ties it together — and that we leaned on — is signal-to-noise: stripping redundant filler makes the tokens that matter a larger fraction of what the model sees, so a shorter prompt can be easier for the model to use, not harder. The system has two halves. One teammate built the RAG course agent — retrieval, agent orchestration, and discoverability via ASI:One (so the agent is reachable with an @ mention). The other built the compression layer that sits between retrieval and generation. This is that layer: Our own compression algorithm. Instead of merely calling a compression API, we wrote our own query-aware extractive compressor, synthesized from the research above. For each piece of retrieved material it: splits text into sentences, scores each sentence by relevance to the course topic / the learner's current question (embedding similarity — the LongLLMLingua question-aware idea), boosts sentences containing definitions, facts, entities, and numbers, removes near-duplicate sentences, keeps the highest-scoring sentences within a token budget (the keep/discard framing from LLMLingua-2), moves high-value sentences toward the front (anti "lost in the middle"), and deletes without ever rewriting — so facts and numbers stay exact (a deliberate choice against SCOPE-style generative rewriting, which could alter a figure and corrupt course accuracy). Roughly, each sentence ss score(s)=w1​⋅rel(s,q)+w2​⋅info(s)−w3​⋅dup(s) info(s) rewards definitions/entities/numbers, and dup(s)\text{dup}(s) dup(s) penalizes redundancy. We keep top-scoring sentences until the token budget is met. Domain-aware split logic. Our layer knows it's compressing course material. It decides what is safe to compress (retrieved chunks, prior Q&A, carried context) versus what must stay exact (system prompt, course schema, the learner's current question). A generic compression API has no idea about these boundaries — send it the whole prompt and it may corrupt what should stay precise. Our layer protects them by design. Domain-aware split logic. Our layer knows it's compressing course material. It decides what is safe to compress (retrieved chunks, prior Q&A, carried context) versus what must stay exact (system prompt, course schema, the learner's current question). A generic compression API has no idea about these boundaries — send it the whole prompt and it may corrupt what should stay precise. Our layer protects them by design. A three-mode framework. Everything runs behind one interface with three swappable modes: none (baseline), local (our own algorithm), and token-company (the commercial API). This means we are not locked to any vendor — we can run entirely on our own compressor with zero external calls — and it lets us benchmark all three head-to-head. A three-mode framework. Everything runs behind one interface with three swappable modes: none (baseline), local (our own algorithm), and token-company (the commercial API). This means we are not locked to any vendor — we can run entirely on our own compressor with zero external calls — and it lets us benchmark all three head-to-head. Resilience. The layer never throws and never blocks generation. If compression fails (API slow/down), it falls back to the original full context and flags it; if retrieval returns nothing, it returns cleanly without a wasted call. The product never breaks because of a compression hiccup. Resilience. The layer never throws and never blocks generation. If compression fails (API slow/down), it falls back to the original full context and flags it; if retrieval returns nothing, it returns cleanly without a wasted call. The product never breaks because of a compression hiccup. The benchmark harness. To prove "quality held," we run paired generations of the same course — once with compressed context, once without — at temperature 0 so compression is the only variable. A blind LLM-as-judge then scores both outputs on accuracy, coverage, and question quality without knowing which is which. We sweep the compression aggressiveness to find the point where quality starts to drop — the safe operating point. The benchmark harness. To prove "quality held," we run paired generations of the same course — once with compressed context, once without — at temperature 0 so compression is the only variable. A blind LLM-as-judge then scores both outputs on accuracy, coverage, and question quality without knowing which is which. We sweep the compression aggressiveness to find the point where quality starts to drop — the safe operating point. A live telemetry dashboard. Tokens before/after, % saved, quality-held as a pair, savings broken down by source, the protected-content panel, and the fallback state — making the otherwise-invisible compression visible in real time. A live telemetry dashboard. Tokens before/after, % saved, quality-held as a pair, savings broken down by source, the protected-content panel, and the fallback state — making the otherwise-invisible compression visible in real time.

## README (from the GitHub repository)

# Diagram Drawing Coach

Learn technical diagrams by **drawing** — circuits, neural networks, flowcharts — with a vision AI coach, shadow reference overlay, and voice guidance.

**ASI:One agent:** deploy `agent/drawing_agent.py` on Agentverse (handle `@diagram-coach`).

## Architecture

```
ASI:One  →  Agentverse (drawing_agent.py)
                →  POST /api/drawing/session  (creates canvas link)
User     →  /draw/[sessionId]  (canvas + vision coach)
                →  POST /api/drawing/coach   (vision feedback)
                →  POST /api/drawing/reference (upload hidden reference)
```

## Local dev

```bash
cp .env.example .env.local   # if you have one
npm install
npm run dev
```

Open [http://localhost:3000](http://localhost:3000) — you land on the canvas to upload a reference and start drawing.

### Required env vars

| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_APP_URL` | Canvas links (e.g. `http://localhost:3000`) |
| `AGENT_API_SECRET` | Auth for Agentverse → API calls |
| `VISION_API_KEY` | Vision coach (or `OPENAI_API_KEY`) |
| `DEEPGRAM_API_KEY` | Optional — Deepgram voice (STT + TTS); falls back to browser speech if unset |

Optional: Supabase for reference image storage (`NEXT_PUBLIC_SUPABASE_URL`, keys).

## Agentverse deploy

See [AGENTVERSE_DEPLOY.md](./AGENTVERSE_DEPLOY.md) and [agent/DRAWING_ASI_ONE_SETUP.md](./agent/DRAWING_ASI_ONE_SETUP.md).

**Agent secrets:**

| Secret | Value |
|--------|--------|
| `DRAWING_APP_URL` | `https://your-app.vercel.app` |
| `AGENT_API_SECRET` | same as Vercel |

**Script:** `agent/drawing_agent.py`  
**Profile README:** paste `agent/DRAWING_AGENT_README.md` in Agentverse agent description / overview (there is no separate README tab on all plans — use the agent profile **Description** or **Overview** field).

## API

| Route | Auth | Purpose |
|-------|------|---------|
| `GET /api/drawing/health` | none | Health check for agent `ping` |
| `POST /api/drawing/session` | agent key or user session | Create canvas session |
| `POST /api/drawing/coach` | none (session id) | Vision coaching |
| `POST /api/drawing/reference` | none | Upload reference image |
| `GET /api/drawing/ghost` | none | Shadow reference image |

Agent auth header: `X-Agent-Api-Key: <AGENT_API_SECRET>`


## Detected evidence (automated analysis)

Indexed codebase: 77 recognized source files, 245 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — 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
- AI coding agent: Claude Code — evidence: commit authorship or trailers
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (85 of 85)

```
.gitignore
agent/DRAWING_AGENT_README.md
agent/drawing_agent.py
agent/DRAWING_ASI_ONE_SETUP.md
agent/register_drawing_agent.py
agent/requirements.txt
AGENTVERSE_DEPLOY.md
data/listings.json
eslint.config.mjs
middleware.ts
next.config.ts
package.json
postcss.config.mjs
README.md
src/app/api/auth/signup/route.ts
src/app/api/drawing/coach/route.ts
src/app/api/drawing/ghost/route.ts
src/app/api/drawing/health/route.ts
src/app/api/drawing/reference/route.ts
src/app/api/drawing/session/[id]/route.ts
src/app/api/drawing/session/route.ts
src/app/api/drawing/voice/config/route.ts
src/app/api/drawing/voice/speak/route.ts
src/app/api/drawing/voice/transcribe/route.ts
src/app/api/health/supabase/route.ts
src/app/auth/callback/route.ts
src/app/draw/[sessionId]/page.tsx
src/app/globals.css
src/app/layout.tsx
src/app/login/page.tsx
src/app/page.tsx
src/components/AppHeader.tsx
src/components/AuthLoadingScreen.tsx
src/components/AuthProvider.tsx
src/components/AuthSuccessOverlay.tsx
src/components/drawing/CoachDock.tsx
src/components/drawing/drawing-ghost-context.tsx
src/components/drawing/DrawingCanvas.tsx
src/components/drawing/DrawingCoachWorkspace.tsx
src/components/drawing/ReferenceShadowLayer.tsx
src/components/drawing/TokenCompressionPanel.tsx
src/hooks/useDrawingCoachLoop.ts
src/lib/agent-auth.ts
src/lib/compression/compressionPipeline.ts
src/lib/compression/compressionTelemetry.ts
src/lib/compression/compressionTypes.ts
src/lib/compression/localCompressor.ts
src/lib/drawing/canvas-bounds.ts
src/lib/drawing/canvas-hints.ts
src/lib/drawing/coach-compression.ts
src/lib/drawing/coach-prompt.ts
src/lib/drawing/deepgram-server.ts
src/lib/drawing/session-store.ts
src/lib/drawing/target-ratio-compressor.ts
src/lib/drawing/utils.ts
src/lib/drawing/vision-coach.ts
src/lib/drawing/voice-client.ts
src/lib/supabase/admin.ts
src/lib/supabase/auth-errors.ts
src/lib/supabase/client.ts
src/lib/supabase/env.ts
src/lib/supabase/errors.ts
src/lib/supabase/server.ts
src/token_compression/benchmarkRunner.test.ts
src/token_compression/compressionPipeline.test.ts
src/token_compression/compressionPipeline.ts
src/token_compression/compressionTelemetry.test.ts
src/token_compression/compressionTelemetry.ts
src/token_compression/compressionTypes.ts
src/token_compression/loadDotEnv.ts
src/token_compression/localCompressor.ts
src/token_compression/mockClients.ts
src/token_compression/openAIClients.ts
src/token_compression/prompts.ts
src/token_compression/runBenchmark.ts
src/token_compression/runner.ts
src/token_compression/sessions.ts
src/token_compression/types.ts
src/token_compression/verifyTokenCompany.ts
src/types/compression.ts
src/types/drawing.ts
supabase/README.md
supabase/schema.sql
tsconfig.json
uagents_core.log
```

### Dependencies

- agent/requirements.txt: requests@>=2.31.0, uagents-core@>=0.4.0
- package.json: @supabase/ssr@^0.12.0, @supabase/supabase-js@^2.108.2, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, eslint@^9, eslint-config-next@16.2.9, next@16.2.9, react@19.2.4, react-dom@19.2.4, tailwindcss@^4, the-token-company@^0.3.2, tldraw@^5.1.1, typescript@^5, zod@^4.4.3

### Recent commits (newest first)

- Add DiagramEasy app name to main workspace header
- token done da
- deep gram done
- new fixes
- agent fixes
- diagram agent
- new drawing
- new chanegs
- Merge pull request #1 from vighanesh2/Compression
- Document compression integration plan
- Add compression telemetry dashboard
- Add paired compression benchmark harness
- Add safe compression engines
- Set up compression pipeline workspace
- fix
- new
- agent done
- rag added
- notifications
- updated code

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

### AGENTVERSE_DEPLOY.md

```markdown
# Diagram Drawing Coach — Deploy Guide

## 1. Deploy Next.js to Vercel

Push to GitHub → import on [vercel.com](https://vercel.com).

| Variable | Value |
|----------|--------|
| `NEXT_PUBLIC_APP_URL` | `https://your-app.vercel.app` |
| `AGENT_API_SECRET` | random secret — **same on Agentverse** |
| `VISION_API_KEY` | OpenAI-compatible vision key |
| `VISION_MODEL` | optional — default `gpt-4o-mini` |
| `NEXT_PUBLIC_SUPABASE_URL` | optional — reference image storage |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | optional |
| `SUPABASE_SERVICE_ROLE_KEY` | optional |

Run `supabase/schema.sql` if using Supabase (drawing sessions + reference bucket).

Health check: `GET https://your-app.vercel.app/api/drawing/health`

---

## 2. Create Agentverse hosted agent

1. [agentverse.ai](https://agentverse.ai) → **+ Launch an Agent** → Blank
2. Name: **Diagram Drawing Coach**
3. Keywords: `diagram`, `drawing`, `sketch`

### Build → Script

Paste all of **`agent/drawing_agent.py`**

### Profile / Description (README)

Agentverse does **not** always show a separate README upload. Paste **`agent/DRAWING_AGENT_README.md`** into:

- Agent **Overview** / **Description**, or
- **Profile** text field in the agent dashboard

This text is what ASI:One uses for discovery.

### Secrets

| Secret | Value |
|--------|--------|
| `DRAWING_APP_URL` | `https://your-app.vercel.app` |
| `AGENT_API_SECRET` | same as Vercel |

Click **Run** → status **Active**

Set handle: **`@diagram-coach`**

---

## 3. Test

### Agentverse chat

Send: `ping`

Expected:

```
Diagram Drawing Coach is online.
App URL: configured
Agent secret: configured
Backend: ok (vision=yes, agentSecret=yes)
```

### ASI:One

1. [asi1.ai](https://asi1.ai) → **Agents** ON
2. *"Teach me to draw an electrical circuit"*
3. Open canvas link → upload reference → **Check my drawing**

---

## Troubleshooting

| Issue | Fix |
|-------|-----|
| `Backend: unreachable` | Wrong `DRAWING_APP_URL`; redeploy Vercel |
| `agentSecret=no` on health | Set `AGENT_API_SECRET` on Vercel and redeploy |
| `401` on session create | Match `AGENT_API_SECRET` on Agentverse and Vercel |
| Canvas link is localhost | Set `NEXT_PUBLIC_APP_URL` on Vercel |
| Vision coach errors | Set `VISION_API_KEY` on Vercel |

```

### agent/DRAWING_AGENT_README.md

```markdown
# Diagram Drawing Coach

An AI agent **discoverable on ASI:One** that teaches you to **draw technical diagrams** — circuits, neural networks, flowcharts, schematics — using a live canvas, vision AI, and voice coaching.

## What it does

1. You tell it what diagram you want to learn (e.g. _electrical circuit_, _neural network_)
2. It sends you a **canvas link**
3. You **upload a reference diagram** (hidden from you — coach sees it)
4. You **draw step-by-step** while the coach:
   - Shows a **faint shadow** of the reference on the canvas
   - Explains **what each part is** (wire, bulb, battery, node, etc.)
   - Gives **dashed hints** for where to draw next when you click **Check my drawing**

## Example prompts (ASI:One)

- "Teach me to draw an electrical circuit diagram"
- "Help me sketch a neural network"
- "I want to learn how to draw a flowchart"
- "Diagram drawing coach for a block diagram"
- "How do I draw a circuit with a battery and light bulb?"

## Keywords for discovery

diagram, drawing, sketch, canvas, drawing coach, learn to draw, electrical circuit, neural network, flowchart, schematic, block diagram, vision coach, trace diagram, technical drawing, ASI agent, Agentverse, education, visual learning

## Handle

`@diagram-coach`

## How it works

```
User (ASI:One)
  → Diagram Drawing Coach (Agentverse, Chat Protocol)
      → Next.js /api/drawing/session (creates canvas link)
          → Drawing Coach workspace (tldraw canvas + vision LLM)
```

## Ideal for

- Engineering students learning circuit schematics
- CS students drawing neural networks / architecture diagrams
- Anyone who learns better by **drawing** than reading

## Quick test

1. [asi1.ai](https://asi1.ai) → **Agents** toggle ON
2. Search: _"diagram drawing coach"_ or `@diagram-coach`
3. Say: _"Teach me to draw an electrical circuit"_
4. Open the link → upload reference → click **Check my drawing**

```

### package.json

```
{
  "name": "diagram-drawing-coach",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@supabase/ssr": "^0.12.0",
    "@supabase/supabase-js": "^2.108.2",
    "next": "16.2.9",
    "react": "19.2.4",
    "react-dom": "19.2.4",
    "the-token-company": "^0.3.2",
    "tldraw": "^5.1.1",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.2.9",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### agent/requirements.txt

```
uagents-core>=0.4.0
requests>=2.31.0

```

### src/app/page.tsx

```typescript
import { randomUUID } from "crypto";
import { redirect } from "next/navigation";

export default function Home() {
  redirect(`/draw/${randomUUID()}`);
}

```

### src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import AppHeader from "@/components/AppHeader";
import { AuthProvider } from "@/components/AuthProvider";
import "tldraw/tldraw.css";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

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

export const metadata: Metadata = {
  title: "Diagram Drawing Coach",
  description:
    "Learn technical diagrams by drawing — vision AI coach with shadow reference guides. ASI:One Agentverse compatible.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
    >
      <body className="min-h-dvh overflow-x-hidden font-sans">
        <AuthProvider>
          <AppHeader />
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}

```

### src/lib/supabase/server.ts

```typescript
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { getSupabaseAnonKey, getSupabaseUrl } from "@/lib/supabase/env";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), {
    cookies: {
      getAll() {
        return cookieStore.getAll();
      },
      setAll(cookiesToSet) {
        try {
          cookiesToSet.forEach(({ name, value, options }) => {
            cookieStore.set(name, value, options);
          });
        } catch {
          // Called from a Server Component — safe to ignore.
        }
      },
    },
  });
}

export async function getAuthUser() {
  if (!process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()) return null;

  try {
    const supabase = await createClient();
    const { data, error } = await supabase.auth.getUser();
    if (error || !data.user) return null;
    return data.user;
  } catch {
    return null;
  }
}

```

### src/app/login/page.tsx

```typescript
"use client";

import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { createClient, isSupabaseConfigured } from "@/lib/supabase/client";
import {
  EMAIL_RATE_LIMIT_HINT,
  formatAuthError,
  isEmailRateLimitError,
} from "@/lib/supabase/auth-errors";
import { supabaseConfigHint } from "@/lib/supabase/env";

type AuthMode = "signin" | "signup";
type Status = "idle" | "loading" | "sent" | "error";

function destinationWithSignedIn(nextPath: string): string {
  return nextPath.includes("?") ? `${nextPath}&signedIn=1` : `${nextPath}?signedIn=1`;
}

function LoginLoading() {
  return (
    <div className="flex min-h-dvh items-center justify-center px-6">
      <p className="text-sm text-notion-muted">Loading…</p>
    </div>
  );
}

function LoginForm() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const callbackError = searchParams.get("error");
  const nextPath = searchParams.get("next") ?? "/";

  const [mode, setMode] = useState<AuthMode>("signin");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [confirmPassword, setConfirmPassword] = useState("");
  const [status, setStatus] = useState<Status>(callbackError ? "error" : "idle");
  const [message, setMessage] = useState(
    callbackError ? formatAuthError(callbackError) : "",
  );

  if (!isSupabaseConfigured()) {
    return (
      <div className="flex min-h-dvh items-center justify-center px-6">
        <div className="max-w-md rounded-2xl border border-notion-border bg-notion-page p-8 text-center shadow-sm">
          <h1 className="text-2xl font-semibold text-notion-text">Sign in unavailable</h1>
          <p className="mt-3 text-sm text-notion-muted">
            Add your Supabase URL and anon/publishable key to <code className="text-xs">.env.local</code>,
            save the file, then restart the dev server.
          </p>
          <p className="mt-2 text-xs text-notion-muted">{supabaseConfigHint()}</p>
          <Link href="/" className="mt-6 inline-block text-sm text-gemini-accent hover:underline">
            Back home
          </Link>
        </div>
      </div>
    );
  }

  const showKeyHint = message.toLowerCase().includes("invalid api key");
  const showRateLimitHint = isEmailRateLimitError(message);
  const loading = status === "loading";

  function authRedirectTo() {
    return `${window.location.origin}/auth/callback?next=${encodeURIComponent(nextPath)}`;
  }

  function switchMode(next: AuthMode) {
    setMode(next);
    setStatus("idle");
    setMessage("");
    setPassword("");
    setConfirmPassword("");
  }

  function setAuthError(rawMessage: string) {
    setStatus("error");
    setMessage(formatAuthError(rawMessage));
  }

  function goAfterAuth() {
    router.push(destinationWithSignedIn(nextPath));
    router.refresh();
  }

  async function handlePasswordAuth(event: React.FormEvent) {
    event.preventDefault();
    const trimmedEmail = email.trim();
    if (!trimmedEmail || !password) return;

    if (mode === "signup" && password !== confirmPassword) {
      setAuthError("Passwords do not match.");
      return;
    }

    if (password.length < 6) {
      setAuthError("Password must be at least 6 characters.");
      return;
    }

    setStatus("loading");
    setMessage("");

    const supabase = createClient();

    if (mode === "signup") {
      const signupResponse = await fetch("/api/auth/signup", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: trimmedEmail, password }),
      });

      const signupData = (await signupResponse.json()) as {
        ok?: boolean;
        error?: string;
        fallback?: boolean;
      };

      if (signupResponse.ok) {
        goAfterAuth();
        return;
      }

      if (signupResponse.status === 409) {
        setAuthError(signupData.error ?? "Account already exists. Sign in instead.");
        setMode("signin");
        return;
      }

      if (!signupData.fallback) {
        setAuthError(signupData.error ?? "Sign up failed.");
        return;
      }

      const { data, error } = await supabase.auth.signUp({
        email: trimmedEmail,
        password,
        options: { emailRedirectTo: authRedirectTo() },
      });

      if (error) {
        setAuthError(error.message);
        return;
      }

      if (data.session) {
        goAfterAuth();
        return;
      }

      setStatus("sent");
      setMessage(
        "Account created. Check your email to confirm, then sign in with your password.",
      );
      return;
    }

    const { error } = await supabase.auth.signInWithPassword({
      email: trimmedEmail,
      password,
    });

    if (error) {
      setAuthError(error.message);
      return;
    }

    goAfterAuth();
  }

  async function handleGoogle() {
    setStatus("loading");
    setMessage("");

    const supabase = createClient();
    const { error } = await supabase.auth.signInWithOAuth({
      provider: "google",
      options: { redirectTo: authRedirectTo() },
    });

    if (error) {
      setAuthError(error.message);
    }
  }

  if (status === "sent") {
    return (
      <div className="flex min-h-dvh items-center justify-center px-6 py-16">
        <div className="w-full max-w-md rounded-2xl border border-notion-border bg-notion-page p-8 text-center shadow-sm">
          <div className="login-sent-icon mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-[var(--notion-callout-green)]">
            <svg
              className="h-8 w-8 text-[#448361]"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="2.5"
              aria-hidden
            >
              <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </div>
          <h1 className="mt-6 t
[truncated — 6896 more characters]
```

### src/app/draw/[sessionId]/page.tsx

```typescript
import { Suspense } from "react";
import DrawingCoachWorkspace from "@/components/drawing/DrawingCoachWorkspace";
import {
  createDrawingSession,
  getDrawingSession,
  getDrawingSessionPublicView,
} from "@/lib/drawing/session-store";

type DrawPageProps = {
  params: Promise<{ sessionId: string }>;
  searchParams: Promise<{ topic?: string }>;
};

export default async function DrawPage({ params, searchParams }: DrawPageProps) {
  const { sessionId } = await params;
  const { topic } = await searchParams;

  let session = getDrawingSession(sessionId);

  if (!session) {
    session = createDrawingSession({
      sessionId,
      topic: topic?.trim() || "Drawing practice",
    });
  }

  return (
    <Suspense fallback={<div className="p-8 text-sm text-neutral-600">Loading drawing coach…</div>}>
      <DrawingCoachWorkspace
        sessionId={sessionId}
        initialSession={getDrawingSessionPublicView(session)}
      />
    </Suspense>
  );
}

export async function generateMetadata({ params }: { params: Promise<{ sessionId: string }> }) {
  const { sessionId } = await params;
  const session = getDrawingSession(sessionId);
  return {
    title: session ? `Draw: ${session.topic}` : "Drawing Coach",
  };
}

```

### src/app/auth/callback/route.ts

```typescript
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { getSupabaseAnonKey, getSupabaseUrl } from "@/lib/supabase/env";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  let next = searchParams.get("next") ?? "/";
  if (!next.startsWith("/")) {
    next = "/";
  }

  if (!code) {
    return NextResponse.redirect(
      `${origin}/login?error=${encodeURIComponent("Missing auth code")}`,
    );
  }

  const signedInNext = next.includes("?") ? `${next}&signedIn=1` : `${next}?signedIn=1`;
  let response = NextResponse.redirect(`${origin}${signedInNext}`);

  const supabase = createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), {
    cookies: {
      getAll() {
        return request.cookies.getAll();
      },
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value, options }) => {
          response.cookies.set(name, value, options);
        });
      },
    },
  });

  const { error } = await supabase.auth.exchangeCodeForSession(code);
  if (error) {
    return NextResponse.redirect(
      `${origin}/login?error=${encodeURIComponent(error.message)}`,
    );
  }

  return response;
}

```

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