# Project export: Scaffold

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: The project aims to reduce both workslop and human resource waste by scaffolding writing routine documents and reports and giving progress indicators in real time.
- Devpost: https://devpost.com/software/scaffold-84plfg
- GitHub: https://github.com/Aakkash-Muthukumar/AI-Hackathon-2026
- Demo: https://docs.google.com/presentation/d/1Sz_TaplLBO3FjcWsN86bbVG1xRwIgRhKgnsMgOWLTp4/edit?usp=sharing
- Video: https://www.youtube.com/embed/ucS7qtno7t0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 7 GitHub contributor(s) — Conbobo (10 commits), Claude Sonnet 4.6 (6 commits), Cursor (5 commits), myth1-cal (4 commits), Bread-Toast877 (2 commits), Aakkash-Muthukumar (1 commits), Connor (1 commits)

## Devpost submission (written by the team)

### Inspiration

The rush toward fully automated AI generation has introduced a costly crisis to the enterprise: "work slop." Roughly 40% of US employees report receiving AI-generated work riddled with critical errors, and 15% of all corporate output now falls into this category. In high-stakes fields like law, finance, and marketing, these unchecked hallucinations create massive legal and financial liabilities. Scaffold solves this by shifting the paradigm from autonomous AI generation to augmented human competence.

### What it does

Scaffold is an AI-powered writing assistant that automatically pulls assignment requirements from applications like Notion, Canvas, etc. with an option to manually input rubrics. By dynamically generating rubric subtasks, verifying context, and updating project milestones, Scaffold gives feedback without AI generating the work. This ensures the human writer remains firmly in control of the core ideas, delivering the accuracy that low-tolerance industries need while providing support in making sure the writing content stays on track.

### How we built it

Backend: FastAPI, Python, BrowserBase Frontend: Next.js, React Hosted on: Plasmo

### Challenges we ran into

We had to think about how to optimize token costs, since updating the rubric progress bars requires re-examining the text written. We decided to make a few triggers for whether the extension decides to evaluate the writing again, based upon whether or not it detects a new paragraph is added or enough of a word difference from the last time it was checked. Accomplishments we’re proud of We’re proud of the UI. We created a logo and animations for the extension, as well as a website that hosts a dashboard for managing lots of tasks and connecting work management apps. We’re also proud that we were able to connect our application across numerous different workspaces.

### What we learned

We learned how difficult it can be to turn an idea into something executable. While we had this idea for a while, it was complicated to break down the end goal into actual technical tasks. Additionally, we learned how to communicate as a team so we could streamline the efficiency of tasks.

### What's next

for scaffold We would want to continue testing the extension, since we didn't have time to write an entire assignment from scratch during the hackathon. We would like to see how the extension changes the rubric progress bars when we type word by word rather than testing on previously finished assignments.

## README (from the GitHub repository)

# Scaffold — AI Writing Companion

Scaffold tracks how complete your work is against assignment requirements **as you write**.
Point it at an assignment (manually or auto-discovered from Canvas, Notion, or Google
Classroom), and Claude breaks the rubric into measurable tasks and scores your draft
0–100% in real time — in a web dashboard and a browser sidebar for Google Docs / Notion.

## Architecture

| Component | Stack | Role |
|-----------|-------|------|
| **Backend** (`backend/`) | FastAPI + Python 3.12 | REST API, Claude calls, caching, platform discovery |
| **Web dashboard** (`web/`) | Next.js 15 + React 19 | Assignment list, detail view, draft editor with live progress |
| **Browser extension** (`extension/`) | Plasmo (Chrome MV3) | Sidebar inside Google Docs / Notion that tracks writing live |

```
clients (web + extension)  ──►  FastAPI  ──►  Claude (rubric analysis + scoring)
                                   │
                                   ├──►  Redis      (cache + progress history)
                                   ├──►  Supabase   (persistent storage)
                                   ├──►  Browserbase + Stagehand (LMS discovery)
                                   ├──►  Sentry      (errors + AI monitoring)
                                   └──►  Arize Phoenix (LLM tracing)
```

### How it works

1. **Create an assignment** — manually via the API/UI, or auto-discovered from a platform.
2. **Analyze the rubric** — Claude decomposes the prompt + rubric into measurable tasks.
3. **Track progress** — as you write, Claude scores each task and surfaces what's missing.
4. **Cache smartly** — Redis skips re-calling Claude unless the document changed ≥100 chars.

## Prerequisites

- Docker & Docker Compose
- Node.js 18+
- Python 3.12 (only if running the backend without Docker)
- API keys: **Anthropic** and **Supabase** are required; **Browserbase**, **Sentry**, and
  **Arize Phoenix** are optional.

## Setup

### 1. Create the Supabase table

Run [`backend/supabase_schema.sql`](backend/supabase_schema.sql) in the Supabase SQL editor
(or via `psql`). It creates the `assignments` table the backend expects.

### 2. Configure environment files

```bash
cp backend/.env.example   backend/.env
cp web/.env.example       web/.env.local
cp extension/.env.example extension/.env
```

Fill in the real values. At minimum the backend needs `ANTHROPIC_API_KEY`,
`SUPABASE_URL`, and `SUPABASE_SERVICE_KEY`.

## Deploy

### Option A — Docker Compose (backend + web + Redis + Phoenix)

```bash
docker compose up --build
```

| Service | URL |
|---------|-----|
| Backend API | http://localhost:8000 |
| API docs (Swagger) | http://localhost:8000/docs |
| Web dashboard | http://localhost:3000 |
| Arize Phoenix | http://localhost:6006 |
| Redis | localhost:6379 |

### Option B — run services individually

**Backend**

```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload   # needs a Redis instance reachable at REDIS_URL
```

**Web dashboard**

```bash
cd web
npm install
npm run dev          # http://localhost:3000
# production: npm run build && npm start
```

**Browser extension**

```bash
cd extension
npm install
npm run dev          # or: npm run build
```

Then in Chrome: **Extensions → Developer mode → Load unpacked** and select the Plasmo
output folder (`extension/build/chrome-mv3-dev` for `dev`, `chrome-mv3-prod` for `build`).
The sidebar injects on `docs.google.com/document/*` and `notion.so`.

## Test

### Backend (pytest)

```bash
cd backend
pip install -r requirements.txt -r requirements-dev.txt
pytest
```

The suite covers pure logic (rubric/JSON parsing, change detection, scraped-data
normalization) and the no-dependency API routes (`/health`, `/api/discovery/supported`).
It runs without any live external services.

### Web (Vitest)

```bash
cd web
npm install
npm test
```

### Manual API smoke test

```bash
# Health
curl http://localhost:8000/health

# Create an assignment (triggers Claude rubric analysis)
curl -X POST http://localhost:8000/api/assignments/ \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Essay on Climate Change",
    "prompt": "Write a 1000-word essay analyzing three causes of climate change.",
    "rubric": [
      {"criterion": "Thesis", "description": "Clear thesis in intro", "points": 20},
      {"criterion": "Evidence", "description": "Three cited sources", "points": 30}
    ]
  }'

# List assignments
curl http://localhost:8000/api/assignments/

# Update progress (replace {id})
curl -X POST http://localhost:8000/api/assignments/{id}/progress \
  -H "Content-Type: application/json" \
  -d '{"assignment_id": "{id}", "document_content": "Climate change is driven by..."}'
```

## Project layout

```
backend/    FastAPI app, services (Claude, Redis, Supabase, Browserbase, Sentry, Arize)
web/        Next.js dashboard
extension/  Plasmo Chrome extension (Google Docs / Notion sidebar)
docker-compose.yml
```

## Environment variables

| Variable | Where | Required | Notes |
|----------|-------|----------|-------|
| `ANTHROPIC_API_KEY` | backend | yes | Claude rubric analysis + scoring |
| `SUPABASE_URL` / `SUPABASE_SERVICE_KEY` | backend | yes | Persistent storage |
| `REDIS_URL` | backend | yes | Defaults to `redis://localhost:6379` |
| `BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` | backend | no | LMS auto-discovery |
| `SENTRY_DSN` | backend | no | Error + AI monitoring |
| `PHOENIX_COLLECTOR_ENDPOINT` / `PHOENIX_API_KEY` | backend | no | LLM tracing |
| `ALLOWED_ORIGINS` | backend | no | CORS allowlist |
| `NEXT_PUBLIC_API_URL` | web | yes | Points the dashboard at the backend |
| `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_ORG` / `SENTRY_PROJECT` | web | no | Sentry |
| `PLASMO_PUBLIC_API_URL` / `PLASMO_PUBLIC_DASHBOARD_URL` | extension | yes | Backend + dashboard URLs |
| `PLASMO_PUBLIC_SENTRY_DSN` | extension | no | Sentry |


## Detected evidence (automated analysis)

Indexed codebase: 70 recognized source files, 272 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- JavaScript (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 (88 of 88)

```
.gitignore
backend/.dockerignore
backend/.env.example
backend/Dockerfile
backend/main.py
backend/models/__init__.py
backend/models/schemas.py
backend/pytest.ini
backend/requirements-dev.txt
backend/requirements.txt
backend/routers/__init__.py
backend/routers/assignments.py
backend/routers/debug.py
backend/routers/discovery.py
backend/routers/evaluate.py
backend/routers/google_auth.py
backend/services/__init__.py
backend/services/arize_service.py
backend/services/assignment_service.py
backend/services/assignment_sync.py
backend/services/browserbase_service.py
backend/services/claude_service.py
backend/services/google_service.py
backend/services/progress_service.py
backend/services/redis_service.py
backend/services/rubric_vector_service.py
backend/services/sentry_service.py
backend/services/supabase_service.py
backend/supabase_schema.sql
backend/tests/conftest.py
backend/tests/test_api.py
backend/tests/test_logic.py
docker-compose.yml
extension/.env.example
extension/package.json
extension/src/background.ts
extension/src/components/MarkIcon.tsx
extension/src/components/RequirementBars.tsx
extension/src/components/ScaffoldLoader.tsx
extension/src/components/ScaffoldLogo.tsx
extension/src/components/Sidebar.tsx
extension/src/components/TaskProgress.tsx
extension/src/contents/dashboard-sync.tsx
extension/src/contents/gdocs-tracker.tsx
extension/src/contents/scaffold-sidebar.tsx
extension/src/lib/brand.ts
extension/src/lib/reqColors.ts
extension/src/popup.tsx
extension/src/styles/gdocs-sidebar.css.ts
extension/tsconfig.json
extension/tsconfig.tsbuildinfo
README.md
web/.dockerignore
web/.env.example
web/app/assignments/[id]/page.tsx
web/app/assignments/new/page.tsx
web/app/connect/page.tsx
web/app/global-error.tsx
web/app/globals.css
web/app/layout.tsx
web/app/page.tsx
web/components/AssignmentCard.tsx
web/components/CompleteOverlay.tsx
web/components/FilterBar.tsx
web/components/Header.tsx
web/components/ProgressBar.tsx
web/components/PromptBody.tsx
web/components/ScaffoldLoader.tsx
web/components/ScaffoldLogo.tsx
web/components/SentryUserSync.tsx
web/components/TaskList.tsx
web/Dockerfile
web/lib/api.ts
web/lib/sentry.ts
web/lib/types.test.ts
web/lib/types.ts
web/lib/userId.ts
web/next-env.d.ts
web/next.config.ts
web/package.json
web/postcss.config.js
web/public/.gitkeep
web/sentry.client.config.ts
web/sentry.server.config.ts
web/tailwind.config.ts
web/tsconfig.json
web/tsconfig.tsbuildinfo
web/vitest.config.ts
```

### Dependencies

- backend/requirements.txt: anthropic@>=0.40.0, arize-phoenix@>=5.7.0, browserbase@>=1.2.0, fastapi@>=0.115.0, fastembed@>=0.6.0, google-api-python-client@>=2.150.0, google-auth-oauthlib@>=1.2.0, httpx@>=0.28.0, numpy@>=1.26.0, openinference-instrumentation-anthropic@>=0.1.15, opentelemetry-sdk@>=1.29.0, playwright@>=1.40.0, pydantic@>=2.10.0, pydantic-settings@>=2.6.0, python-docx@>=1.1.0, python-dotenv@>=1.0.0, python-multipart@>=0.0.20, redis[hiredis]@>=5.2.0, sentry-sdk[fastapi]@>=2.19.0, stagehand@>=3.0.0,<4, supabase@>=2.10.0, uvicorn[standard]@>=0.32.0
- extension/package.json: @sentry/browser@^8.46.0, @types/chrome@^0.0.270, @types/node@^26.0.0, @types/react@^18.2.0, @types/react-dom@^18.2.0, clsx@^2.1.1, lucide-react@^0.468.0, plasmo@^0.89.0, react@^18.2.0, react-dom@^18.2.0, typescript@^5.7.2
- web/package.json: @sentry/nextjs@^8.46.0, @types/node@^22.0.0, @types/react@^19.0.0, @types/react-dom@^19.0.0, autoprefixer@^10.4.20, clsx@^2.1.1, date-fns@^4.1.0, lucide-react@^0.468.0, next@^15.3.0, postcss@^8.5.0, react@^19.0.0, react-dom@^19.0.0, tailwindcss@^3.4.17, typescript@^5.7.2, vitest@^2.1.8

### Recent commits (newest first)

- Fixed more stuff
- Fixed Connections
- Fixed Everything
- UI UX update
- BrowserBaseAttempt
- UI UX update
- Dashboard Adjustments -Mira
- Frontend Adjustments -Mira
- Frontend Adjustments -Mira
- Merge branch 'main' of https://github.com/Aakkash-Muthukumar/AI-Hackathon-2026
- Browserbase fix
- SQL Adjustment -Mira
- Improve Google Docs extension UX and fix assignment visibility.
- Add files via upload
- Add files via upload
- variousGoogleChanges
- Fix live Docs eval cache so sidebar updates while typing.
- Fix Google OAuth and document fetching for the Docs extension tracker.
- Add live Google Docs requirement tracker
- Implement Browserbase live-view account linking flow

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

### docker-compose.yml

```yaml
services:
  backend:
    build: ./backend
    ports:
      - "8000:8000"
    env_file: ./backend/.env
    environment:
      - REDIS_URL=redis://redis:6379
      - PHOENIX_COLLECTOR_ENDPOINT=http://phoenix:6006/v1/traces
    depends_on:
      - redis
      - phoenix
    volumes:
      - ./backend:/app
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

  redis:
    image: redis/redis-stack-server:latest
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    environment:
      - REDIS_ARGS=--save 60 1 --appendonly yes

  phoenix:
    image: arizephoenix/phoenix:latest
    ports:
      - "6006:6006"
    volumes:
      - phoenix_data:/phoenix

  web:
    build: ./web
    ports:
      - "3000:3000"
    env_file: ./web/.env.local
    environment:
      - NEXT_PUBLIC_API_URL=http://localhost:8000/api
    depends_on:
      - backend

volumes:
  redis_data:
  phoenix_data:

```

### backend/Dockerfile

```
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
    && python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"

COPY . .

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

```

### backend/requirements.txt

```
anthropic>=0.40.0
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.10.0
pydantic-settings>=2.6.0
redis[hiredis]>=5.2.0
fastembed>=0.6.0
numpy>=1.26.0
supabase>=2.10.0
browserbase>=1.2.0
stagehand>=3.0.0,<4
playwright>=1.40.0
sentry-sdk[fastapi]>=2.19.0
arize-phoenix>=5.7.0
openinference-instrumentation-anthropic>=0.1.15
opentelemetry-sdk>=1.29.0
python-multipart>=0.0.20
httpx>=0.28.0
python-dotenv>=1.0.0
google-auth-oauthlib>=1.2.0
google-api-python-client>=2.150.0
python-docx>=1.1.0

```

### web/package.json

```
{
  "name": "scaffold-web",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "dependencies": {
    "next": "^15.3.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "@sentry/nextjs": "^8.46.0",
    "clsx": "^2.1.1",
    "date-fns": "^4.1.0",
    "lucide-react": "^0.468.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.5.0",
    "tailwindcss": "^3.4.17",
    "typescript": "^5.7.2",
    "vitest": "^2.1.8"
  }
}

```

### web/Dockerfile

```
# Multi-stage build for the Next.js dashboard (standalone output).
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* values are inlined at build time; can be overridden at runtime
# for server components but client bundles use the build-time value.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000

RUN addgroup --system --gid 1001 nodejs \
  && adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

```

### extension/package.json

```
{
  "name": "scaffold-extension",
  "displayName": "Scaffold — Writing Companion",
  "version": "1.0.0",
  "description": "Track your writing progress against assignment requirements",
  "private": true,
  "scripts": {
    "dev": "plasmo dev",
    "build": "plasmo build",
    "package": "plasmo package"
  },
  "dependencies": {
    "@sentry/browser": "^8.46.0",
    "clsx": "^2.1.1",
    "lucide-react": "^0.468.0",
    "plasmo": "^0.89.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/chrome": "^0.0.270",
    "@types/node": "^26.0.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "typescript": "^5.7.2"
  },
  "manifest": {
    "name": "Scaffold — Writing Companion",
    "description": "Track your assignment progress as you write",
    "permissions": [
      "storage",
      "activeTab",
      "scripting",
      "tabs"
    ],
    "host_permissions": [
      "http://localhost:8000/*",
      "http://127.0.0.1:8000/*",
      "http://localhost:3000/*",
      "http://127.0.0.1:3000/*",
      "https://docs.google.com/*",
      "https://www.notion.so/*",
      "https://notion.so/*"
    ]
  }
}

```

### backend/main.py

```python
import os
import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware

from services import arize_service, rubric_vector_service, sentry_service
from routers import assignments, discovery, google_auth, evaluate, debug

sentry_service.init()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@asynccontextmanager
async def lifespan(app: FastAPI):
    arize_service.initialize()
    await rubric_vector_service.ensure_index()
    logger.info("Scaffold backend ready")
    yield


app = FastAPI(
    title="Scaffold API",
    description="Assignment-completion tracking system",
    version="1.0.0",
    lifespan=lifespan,
)

origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(assignments.router, prefix="/api")
app.include_router(discovery.router, prefix="/api")
app.include_router(evaluate.router, prefix="/api")
app.include_router(debug.router, prefix="/api")
app.include_router(google_auth.router)   # /auth/google/* — no /api prefix (browser redirects)


@app.middleware("http")
async def sentry_user_context(request: Request, call_next):
    """Attach X-User-ID to Sentry events for cross-client debugging."""
    user_id = request.headers.get("X-User-ID")
    if user_id:
        sentry_service.set_user(user_id)
    sentry_service.add_breadcrumb(
        "http",
        f"{request.method} {request.url.path}",
        data={"has_user_id": bool(user_id)},
    )
    return await call_next(request)


@app.get("/health")
async def health():
    return {
        "status": "ok",
        "service": "scaffold-api",
        "sentry": sentry_service.is_enabled(),
    }

```

### web/app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";
import { SentryUserSync } from "@/components/SentryUserSync";

export const metadata: Metadata = {
  title: "Scaffold — Complete Your Writing",
  description:
    "AI-powered assignment completion system that tracks your actual writing progress",
  icons: { icon: "/favicon.svg" },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-gray-50 text-gray-900 antialiased">
        <SentryUserSync />
        {children}
      </body>
    </html>
  );
}

```

### web/app/page.tsx

```typescript
"use client";

import { useEffect, useState, useMemo } from "react";
import { Assignment, getUrgency, UrgencyLevel, AssignmentSource } from "@/lib/types";
import { api } from "@/lib/api";
import { AssignmentCard } from "@/components/AssignmentCard";
import { FilterBar } from "@/components/FilterBar";
import { Header } from "@/components/Header";
import { ScaffoldLoader } from "@/components/ScaffoldLoader";
import { PlusCircle, RefreshCw } from "lucide-react";
import Link from "next/link";

interface Filters {
  urgency: UrgencyLevel | "all";
  source: AssignmentSource | "all";
  search: string;
}

export default function Dashboard() {
  const [assignments, setAssignments] = useState<Assignment[]>([]);
  const [loading, setLoading] = useState(true);
  const [filters, setFilters] = useState<Filters>({
    urgency: "all",
    source: "all",
    search: "",
  });

  async function load() {
    setLoading(true);
    try {
      const data = await api.assignments.list();
      setAssignments(data);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { load(); }, []);

  const filtered = useMemo(() => {
    return assignments.filter((a) => {
      if (filters.urgency !== "all" && getUrgency(a.deadline) !== filters.urgency)
        return false;
      if (filters.source !== "all" && a.source !== filters.source) return false;
      if (
        filters.search &&
        !a.title.toLowerCase().includes(filters.search.toLowerCase())
      )
        return false;
      return true;
    });
  }, [assignments, filters]);

  const overallAvg =
    assignments.length > 0
      ? assignments.reduce((s, a) => s + a.overall_completion, 0) /
        assignments.length
      : 0;

  return (
    <div className="min-h-screen bg-gray-50">
      <Header />

      <main className="max-w-6xl mx-auto px-6 py-8">
        {/* Hero stats */}
        <div className="mb-8 grid grid-cols-3 gap-4">
          <StatCard label="Assignments" value={assignments.length} />
          <StatCard
            label="Avg completion"
            value={`${overallAvg.toFixed(0)}%`}
          />
          <StatCard
            label="Overdue"
            value={assignments.filter((a) => getUrgency(a.deadline) === "overdue").length}
            alert
          />
        </div>

        {/* Controls */}
        <div className="flex items-center justify-between mb-6 gap-4 flex-wrap">
          <h1 className="text-xl font-bold">Your assignments</h1>
          <div className="flex items-center gap-3">
            <FilterBar
              filters={filters}
              onChange={(f) => setFilters((prev) => ({ ...prev, ...f }))}
            />
            <button
              onClick={load}
              className="p-2 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
              title="Refresh"
            >
              <RefreshCw size={16} />
            </button>
            <Link
              href="/assignments/new"
              className="flex items-center gap-1.5 px-4 py-2 bg-scaffold-500 text-white text-sm font-medium rounded-lg hover:bg-scaffold-600 transition-colors"
            >
              <PlusCircle size={16} />
              New
            </Link>
          </div>
        </div>

        {/* Grid */}
        {loading ? (
          <div className="flex justify-center py-24">
            <ScaffoldLoader width={72} label="Loading assignments…" />
          </div>
        ) : filtered.length === 0 ? (
          <div className="text-center py-20 text-gray-400">
            <p className="text-lg mb-2">No assignments found</p>
            <p className="text-sm">
              Add one manually or{" "}
              <Link href="/connect" className="text-scaffold-500 hover:underline">
                connect a platform
              </Link>{" "}
              to auto-discover.
            </p>
          </div>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {filtered.map((a) => (
              <AssignmentCard key={a.id} assignment={a} />
            ))}
          </div>
        )}
      </main>
    </div>
  );
}

function StatCard({
  label,
  value,
  alert = false,
}: {
  label: string;
  value: string | number;
  alert?: boolean;
}) {
  return (
    <div className="bg-white rounded-xl border border-gray-200 p-5">
      <p className="text-sm text-gray-500 mb-1">{label}</p>
      <p
        className={`text-3xl font-bold ${
          alert && Number(value) > 0 ? "text-red-500" : "text-gray-900"
        }`}
      >
        {value}
      </p>
    </div>
  );
}

```

### web/app/connect/page.tsx

```typescript
"use client";

import { useEffect, useState, useCallback, useRef } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { getUserId } from "@/lib/userId";
import { Header } from "@/components/Header";
import { ScaffoldLoader } from "@/components/ScaffoldLoader";
import {
  ArrowLeft,
  CheckCircle2,
  Lock,
  ChevronRight,
  LogOut,
  MonitorSmartphone,
} from "lucide-react";

interface Platform {
  id: string;
  name: string;
  status: string;
}

type Step = "idle" | "opening" | "live" | "scraping" | "done";

interface ActiveSession {
  platform: string;
  sessionId: string;
  contextId: string;
  liveViewUrl: string;
  startUrl?: string | null;
  preferNewTab?: boolean;
}

export default function Connect() {
  const [platforms, setPlatforms] = useState<Platform[]>([]);
  const [connected, setConnected] = useState<string[]>([]);
  const [loading, setLoading] = useState(true);
  const [step, setStep] = useState<Step>("idle");
  const [session, setSession] = useState<ActiveSession | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [doneMessage, setDoneMessage] = useState<string | null>(null);
  const [newAssignmentCount, setNewAssignmentCount] = useState<number | null>(null);
  const [liveViewDisconnected, setLiveViewDisconnected] = useState(false);
  const [reconnecting, setReconnecting] = useState(false);
  const [iframeKey, setIframeKey] = useState(0);
  const [cancelling, setCancelling] = useState(false);
  const sessionRef = useRef<ActiveSession | null>(null);
  const stepRef = useRef<Step>("idle");

  const userId = getUserId();

  const loadData = useCallback(async () => {
    setLoading(true);
    try {
      const [{ platforms: ps }, { connected_platforms }] = await Promise.all([
        api.discovery.supported(),
        api.discovery.status(userId),
      ]);
      setPlatforms(ps);
      setConnected(connected_platforms);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Failed to load platforms");
    } finally {
      setLoading(false);
    }
  }, [userId]);

  useEffect(() => {
    loadData();
  }, [loadData]);

  useEffect(() => {
    sessionRef.current = session;
  }, [session]);

  useEffect(() => {
    stepRef.current = step;
  }, [step]);

  async function handleConnect(platformId: string) {
    setStep("opening");
    setError(null);
    setDoneMessage(null);
    try {
      const res = await api.discovery.connect(platformId, userId);
      const nextSession: ActiveSession = {
        platform: platformId,
        sessionId: res.session_id,
        contextId: res.context_id,
        liveViewUrl: res.live_view_url,
        startUrl: res.start_url,
        preferNewTab: res.prefer_new_tab,
      };
      setSession(nextSession);
      setLiveViewDisconnected(false);
      setIframeKey((k) => k + 1);
      setStep("live");
      if (res.prefer_new_tab) {
        window.open(res.live_view_url, "_blank", "noopener,noreferrer");
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "Could not open browser session");
      setStep("idle");
    }
  }

  async function handleScrape() {
    if (!session) return;
    setStep("scraping");
    setError(null);
    setDoneMessage(null);
    setNewAssignmentCount(null);

    const platform = session.platform;
    const sessionId = session.sessionId;
    const contextId = session.contextId;

    try {
      const res = await api.discovery.scrape(
        platform,
        sessionId,
        contextId,
        userId
      );
      setSession(null);
      setStep("done");

      if (res.status === "empty") {
        setError(res.message);
        setStep("idle");
        return;
      }

      const saved = res.assignments_saved ?? 0;
      if (saved > 0) {
        setNewAssignmentCount(saved);
        setDoneMessage(res.message);
        setConnected((prev) => (prev.includes(platform) ? prev : [...prev, platform]));
      } else {
        setError(res.message || "Scan finished but no assignments were saved.");
        setStep("idle");
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "Scan failed");
      setSession(null);
      setStep("idle");
    }
  }

  // Browserbase live view posts this when its WebSocket drops
  useEffect(() => {
    function onMessage(event: MessageEvent) {
      if (event.data === "browserbase-disconnected") {
        setLiveViewDisconnected(true);
      }
    }
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []);

  async function handleReconnectLiveView() {
    if (!session) return;
    setReconnecting(true);
    setError(null);
    try {
      const res = await api.discovery.refreshLiveView(session.sessionId);
      setSession({ ...session, liveViewUrl: res.live_view_url });
      setLiveViewDisconnected(false);
      setIframeKey((k) => k + 1);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Could not reconnect live browser");
    } finally {
      setReconnecting(false);
    }
  }

  async function handleCancel() {
    const active = sessionRef.current;
    setCancelling(true);
    setSession(null);
    setStep("idle");
    setError(null);
    setLiveViewDisconnected(false);
    try {
      if (active?.sessionId) {
        await api.discovery.cancelSession(active.sessionId);
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "Could not close browser session");
    } finally {
      setCancelling(false);
    }
  }

  // Terminate orphaned connect sessions if the user leaves mid-login
  useEffect(() => {
    return () => {
      if (stepRef.current !== "live") return;
      const active = sessionRef.current;
      if (active?.sessionId) {
        api.discovery.cancelSession(active.sessionId).catch(() => {});
      }
    };
  }, []);

  async function handleDisconnect(platformId: string) {
    try {
      await api.discovery.disconnect(userId, platformId);
      setConne
[truncated — 9564 more characters]
```

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