# Project export: Patina

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: TreeHacks 2026
- Tagline: A creative canvas that learns and adapts to your aesthetic taste as you curate content.
- Devpost: https://devpost.com/software/patina
- GitHub: https://github.com/yusufsallam64/patina
- Demo: https://patina-theta.vercel.app/
- Video: https://www.youtube.com/embed/Ftekgz9gRFM?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Yusuf Sallam (5 commits)

## Devpost submission (written by the team)

### Overview

A creative canvas that analyzes your aesthetic taste as you curate, then searches, discovers, and gives you more content to explore.

### Inspiration

As a photographer and aspiring creative, finding references and high-quality content to consume is incredibly important, but interesting content online spans a wide range of modalities which makes it hard to consolidate in one place. Screenshots, links, color palettes, songs, typefaces across tons of browser tabs. Tools like Pinterest support a subset of these modalities, but they treat these tools like static bookmarks. They don't understand what ties your collections together, and they can't help you find what's missing. We wanted to build a tool where the act of curating is itself a creative input. Drop in the things that inspire you, and the system extracts the underlying aesthetic and uses that understanding to actively help you explore and create. Discovery shouldn't be the bottleneck; developing your taste is.

### What it does

Patina is an infinite canvas mood board where every reference you add contributes to a living "vibe profile." You can drop in images, paste URLs to articles or portfolios, embed YouTube videos and Spotify tracks, preview Google Fonts, or just type raw text. Each piece gets analyzed by Claude to extract its aesthetics and how it contributes to the board's creative direction. As your board grows, Patina continuously recomputes a composite vibe from all your references, weighted by proximity to the center of your canvas. This composite vibe powers several features: Vibe-aware discovery: An AI-powered discovery deck suggests new references — images, articles, music, typography — that complement your existing collection's aesthetic. It uses Perplexity Sonar to search across domains you wouldn't think to look in. Vibe-aware search: Search the web through the lens of your board's aesthetic. Ask for "brutalist architecture" and only content that suits your board is returned. Deep interviews: The discovery system interviews you about your creative intent, asking targeted questions to refine its understanding of what you're looking for. Style guide generation: Materialize your board's vibe into a concrete brand style guide (color palettes, typography pairings, CSS variables, and design tokens) to add the vibe to any real project. Embeddable media: YouTube, Spotify, Vimeo, and SoundCloud content plays inline on the canvas. Google Fonts render live type specimens using your vibe narrative as sample text. Spacially-Aware Inspiration: Works influence each other based on proximity. Spacial positioning adds another layer of depth to curation. Content Remixing: Generative models allow you to combine various multimodal forms of content to spark new, novel inspiration.

### How we built it

Patina is a Next.js application with a React Flow (XYFlow) canvas at its core. The frontend uses Zustand for state management with localStorage persistence, Framer Motion for animations, and Tailwind CSS v4 for styling. The vibe extraction pipeline sends each reference to Claude (Sonnet 4.5), which analyzes images, URLs (including their og:image previews), and text to analyze the media's colors, aesthetic, and "vibe". These per-node contributions are merged into a composite vibe using proximity-weighted blending. The discovery engine calls Perplexity Sonar with prompts constructed from the composite vibe profile, requesting cross-domain creative references. An interview system uses Claude to generate targeted follow-up questions that refine discovery results. Style guide generation sends the full vibe profile to Claude with a detailed prompt to produce production-ready design tokens and brand guidelines. URL nodes fetch metadata via Cheerio for server-side HTML parsing, extracting og:image, title, and body text. OEmbed integration handles embeddable URLs from YouTube, Spotify, Vimeo, and SoundCloud. Challenges we faced Iframe embedding doesn't work for most sites — CSP and X-Frame-Options headers block it. We pivoted to using og:image as visual previews instead, which ended up looking better anyway. Tailwind classes not applying inside React Flow nodes — React Flow's measurement system interfered with certain Tailwind utilities like max-h-[240px]. We solved this with targeted inline styles where needed. Vibe extraction accuracy for URLs — Text-only analysis missed the visual character of a site. Including the og:image as a visual input to Claude dramatically improved color and mood extraction. Balancing extraction latency with UX — Vibe extraction calls take a few seconds per node. We implemented async extraction with loading states and deferred composite recomputation to keep the canvas responsive. Recommendation accuracy — Making sure that recommendations pushed past surface-level content was difficult but made significantly easier thanks to Perplexity's Sonar Pro model.

### Accomplishments we're proud of

The overall UI/UX of the project came out looking really nice! Everything is intuitive, quick, and feels polished. For a creative tool, this was really something that I wanted to make sure I got right. Since positioning and content suggestion feels seamless, users can focus on curation rather than fighting against the interface. The recommendation engine also works incredibly well and consistently provided me with new articles, works of art, and songs that fit the vibe I was going for perfectly and proved to be quite interesting at the same time.

### What we learned

In this current AI age, taste is a commodity that should be respected. Powerful AI applications can generate anything, but none can replace human judgment; they merely amplify one's taste. The system works because it respects individual curation decisions as the source of truth and merely provides suggestions that push aggregated materials into useful and potentially novel directions. Aesthetic understanding is also surprisingly tractable for large language models when you give them structured output formats and visual inputs. Claude's ability to extract consistent, usable color palettes and mood descriptors from diverse inputs (photos, articles, music, typography) was better than expected.

### What's next

Adding the ability to directly share content from inside any app and drop it into the board would make the experience significantly more seamless. Unfortunately this is a lot of integrations that I did not have time to build. Adding a sequential node system would also be really interesting, where elements higher in the tree/chain of elements have a stronger influence on the next set of generated content. Collaborative boards and more tool-export utilities would be awesome (e.g. LUTs for videos, Tailwind config files, Figma design plugin, etc.). Built with Next.js 16 — React framework with App Router and API routes React 19 — UI rendering XYFlow (React Flow) — Infinite canvas with draggable nodes Zustand — Lightweight state management Framer Motion — Animations and transitions Tailwind CSS v4 — Styling Claude (Anthropic API) — Vibe extraction, style guide generation, discovery interviews, vibe narratives Perplexity Sonar API — Cross-domain vibe-aware web search and discovery Cheerio — Server-side HTML parsing for URL metadata extraction TypeScript — Type safety throughout Vercel — Deployment

## README (from the GitHub repository)

# Patina

**A creative canvas that analyzes your aesthetic taste as you curate, then searches, discovers, and gives you more content to explore.**

Patina is an infinite canvas mood board where every reference you add contributes to a living "vibe profile." Drop in images, paste URLs to articles or portfolios, embed YouTube videos and Spotify tracks, preview Google Fonts, or type raw text. Each piece gets analyzed by Claude to extract its aesthetic DNA — colors, mood, texture, and sonic qualities — and the system continuously recomputes a composite vibe from all your references, weighted by spatial proximity on the canvas.

That composite vibe powers vibe-aware discovery (cross-domain suggestions via Perplexity Sonar Pro), vibe-aware search (web results filtered through your board's aesthetic), deep interviews (targeted questions to refine creative intent), style guide generation (color palettes, typography, CSS variables, design tokens), and inline embeddable media. Spatial positioning matters — works influence each other based on proximity, adding another layer of depth to curation.

Discovery shouldn't be the bottleneck; developing your taste is.

## Tech Stack

- **Next.js 16** with App Router and API routes
- **React 19** + **XYFlow (React Flow)** for the infinite canvas
- **Zustand** for state management with localStorage persistence
- **Framer Motion** for animations, **Tailwind CSS v4** for styling
- **Claude (Anthropic API)** — vibe extraction, style guide generation, discovery interviews, vibe narratives
- **Perplexity Sonar Pro API** — cross-domain vibe-aware web search and discovery
- **Cheerio** for server-side URL metadata parsing, **OEmbed** for media embeds
- **TypeScript** throughout, deployed on **Vercel**

## Getting Started

```bash
npm install
npm run dev
```

Open [http://localhost:3000](http://localhost:3000) to see the canvas.

You'll need the following environment variables:

```
ANTHROPIC_API_KEY=
PERPLEXITY_API_KEY=
```


## Detected evidence (automated analysis)

Indexed codebase: 46 recognized source files, 282 KB.
- Anthropic (technology) — detected in the code
- 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
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (51 of 51)

```
.gitignore
app/api/discover/route.ts
app/api/discovery-interview/route.ts
app/api/extract-vibe/route.ts
app/api/generate-from-context/route.ts
app/api/generate-image/route.ts
app/api/generate-music/route.ts
app/api/generate-ui/route.ts
app/api/music-status/route.ts
app/api/oembed/route.ts
app/api/parse-url/route.ts
app/api/style-transfer/route.ts
app/api/upload/route.ts
app/api/vibe-narrative/route.ts
app/globals.css
app/layout.tsx
app/page.tsx
components/canvas/BoardSwitcher.tsx
components/canvas/ContextMenu.tsx
components/canvas/EmptyCanvas.tsx
components/canvas/EnergyField.tsx
components/canvas/GlowCanvas.tsx
components/canvas/PatinaCanvas.tsx
components/discovery/DiscoveryDeck.tsx
components/discovery/HiddenNodesBadge.tsx
components/nodes/CodeNode.tsx
components/nodes/DismissButton.tsx
components/nodes/ImageNode.tsx
components/nodes/MusicNode.tsx
components/nodes/TextNode.tsx
components/nodes/URLNode.tsx
components/vibe/MoodTags.tsx
components/vibe/PaletteDisplay.tsx
components/vibe/VibePanel.tsx
components/vibe/VibeStrip.tsx
eslint.config.mjs
hooks/useVibeExtraction.ts
lib/classify.ts
lib/claude.ts
lib/perplexity.ts
lib/proximity.ts
lib/spaces.ts
lib/store.ts
modal_functions/image_gen.py
modal_functions/style_transfer.py
next.config.ts
package.json
postcss.config.mjs
README.md
tsconfig.json
types/index.ts
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.74.0, @aws-sdk/client-s3@^3.990.0, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, @xyflow/react@^12.10.0, cheerio@^1.2.0, eslint@^9, eslint-config-next@16.1.6, framer-motion@^12.34.0, next@16.1.6, react@19.2.3, react-dom@19.2.3, react-markdown@^10.1.0, tailwindcss@^4, typescript@^5, zustand@^5.0.11

### Recent commits (newest first)

- Update readme
- Add manual vibe-inspired web searching
- Enhance recommendations, OEmbeds
- Adjust styling and structure, improve discovery functionality
- Improve generation suggestions and layout/designs
- Resolve audio playback issues from Suno
- Enhance classification, resolve drag/drop issues with nodes, improve styling
- Impl music API w/ Suno, style transfers, code gen
- Init project
- Initial commit from Create Next App

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

### package.json

```
{
  "name": "patina",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.74.0",
    "@aws-sdk/client-s3": "^3.990.0",
    "@xyflow/react": "^12.10.0",
    "cheerio": "^1.2.0",
    "framer-motion": "^12.34.0",
    "next": "16.1.6",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-markdown": "^10.1.0",
    "zustand": "^5.0.11"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### app/page.tsx

```typescript
"use client";

import { PatinaCanvas } from "@/components/canvas/PatinaCanvas";
import { VibeStrip, VibePalette } from "@/components/vibe/VibeStrip";
import { BoardSwitcher } from "@/components/canvas/BoardSwitcher";
import { HiddenNodesBadge } from "@/components/discovery/HiddenNodesBadge";

export default function Home() {
  return (
    <div className="h-screen w-screen relative">
      <PatinaCanvas />
      <div className="absolute top-3 left-3 z-[40] flex items-center gap-3">
        <BoardSwitcher />
        <VibePalette />
        <HiddenNodesBadge />
      </div>
      <VibeStrip />
    </div>
  );
}

```

### app/layout.tsx

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

export const metadata: Metadata = {
  title: "Patina",
  description: "Your creative taste, made actionable.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className="dark">
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
        <link
          href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500&display=swap"
          rel="stylesheet"
        />
        <link
          href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
          rel="stylesheet"
        />
      </head>
      <body className="font-sans antialiased bg-[var(--background)] text-[var(--foreground)] overflow-hidden">
        {children}
      </body>
    </html>
  );
}

```

### types/index.ts

```typescript
import type { Node, Edge } from "@xyflow/react";

// ─── Node Data Types ─────────────────────────────────────────────

export type PatinaNodeType =
  | "image"
  | "text"
  | "url"
  | "vibe"
  | "styled-photo"
  | "code"
  | "music"
  | "suggested";

export interface PatinaNodeData extends Record<string, unknown> {
  type: PatinaNodeType;
  content: string; // URL, base64, text, or generated content
  vibeContribution?: VibeContribution;
  metadata?: Record<string, unknown>;
  // For suggested nodes
  dismissed?: boolean;
  // For styled-photo nodes
  originalImageUrl?: string;
  // For code nodes
  previewHtml?: string;
  // For music nodes
  audioUrl?: string;
  title?: string;
  // Loading state for placeholder nodes
  isLoading?: boolean;
  // For URL / text nodes created from URLs
  originalText?: string;
  summaryText?: string;
  sourceUrl?: string;
  ogImage?: string;
  // For embeddable URLs (YouTube, Spotify, etc.)
  embedHtml?: string;
  embedProvider?: string;
  embedThumbnail?: string;
}

export type PatinaNode = Node<PatinaNodeData>;
export type PatinaEdge = Edge;

// ─── Dropped Content (before classification) ─────────────────────

export interface DroppedContent {
  raw: string | File;
  sourceType: "file" | "paste" | "url" | "text";
  position: { x: number; y: number };
}

// ─── Vibe Types ──────────────────────────────────────────────────

export interface CSSFilters {
  brightness: number;
  contrast: number;
  saturate: number;
  "hue-rotate": string;
  sepia: number;
}

export interface VibeContribution {
  colors: string[];
  mood_tags: string[];
  aesthetic_tags: string[];
  warmth: number;
  contrast: number;
  saturation: number;
  texture: string;
  css_filters: CSSFilters;
  sonic_mood?: string;
}

export interface VibeProfile {
  color_palette: {
    dominant: string[];
    accent: string[];
    background_tone: "warm" | "cool" | "neutral";
  };
  mood: string;
  mood_tags: string[];
  lighting: { warmth: number; contrast: number };
  texture: string;
  saturation: number;
  brightness: number;
  aesthetic_tags: string[];
  css_filters: CSSFilters;
  sonic_mood: string;
}

// ─── Discovery Types ─────────────────────────────────────────────

export type ContentDomain = "essay" | "music" | "video" | "typography" | "image" | "visual";

export interface SuggestedReference {
  id: string;
  type: "image" | "text" | "url";
  content: string; // image URL for images, snippet for text, page URL for urls
  title?: string;
  originUrl?: string;
  width?: number;
  height?: number;
  query?: string;
  domain?: ContentDomain;
}

// ─── Interview Types ────────────────────────────────────────────

export interface InterviewQuestion {
  question: string;
  optionA: string;
  optionB: string;
}

export interface InterviewAnswer {
  question: string;
  answer: string;
  context?: string;
}

// ─── API Request/Response Types ──────────────────────────────────

export interface ExtractVibeRequest {
  content: string;
  type: "image" | "text" | "url";
}

export interface ExtractVibeResponse {
  contribution: VibeContribution;
}

export interface MergeVibeRequest {
  contributions: {
    nodeId: string;
    vibe: VibeContribution;
    weight: number;
  }[];
}

export interface MergeVibeResponse {
  profile: VibeProfile;
}

export interface DiscoverRequest {
  vibe: VibeProfile;
}

export interface DiscoverResponse {
  suggestions: SuggestedReference[];
  relatedQuestions?: string[];
}

export interface StyleTransferRequest {
  style_references: string[];
  target_image: string;
  prompt: string;
  strength?: number;
}

export interface StyleTransferResponse {
  imageUrl: string;
}

export interface GenerateUIRequest {
  vibe: VibeProfile;
  user_input: string;
}

export interface GenerateUIResponse {
  code: string;
  preview_html: string;
}

export interface GenerateMusicRequest {
  sonic_mood: string;
  mood_tags: string[];
  duration?: number;
}

export interface GenerateMusicResponse {
  audio_url: string;
  title: string;
}

export interface UploadResponse {
  url: string;
}

export interface ParseUrlResponse {
  title: string;
  description: string;
  text: string;
  bodyText: string;
  images: string[];
  ogImage?: string;
}

// ─── Proximity-Based Generation Types ───────────────────────────

export type GenerationMode = "remix" | "restyle" | "text";

export interface NearbyNodeContext {
  type: PatinaNodeType;
  content: string;
  vibeContribution?: VibeContribution;
  weight: number;
}

export interface GenerateFromContextRequest {
  nearby_nodes: NearbyNodeContext[];
  mode: GenerationMode;
  target_image?: string;
}

export interface GenerateFromContextResponse {
  imageUrl?: string;
  text?: string;
  prompt_used?: string;
}

```

### app/api/upload/route.ts

```typescript
import { NextResponse } from "next/server";
import { uploadToSpaces } from "@/lib/spaces";
import type { UploadResponse } from "@/types";

export async function POST(request: Request) {
  try {
    const formData = await request.formData();
    const file = formData.get("file") as File;

    if (!file) {
      return NextResponse.json({ error: "No file provided" }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const ext = file.name.split(".").pop() || "png";
    const key = `references/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;

    const url = await uploadToSpaces(buffer, key, file.type);

    return NextResponse.json({ url } satisfies UploadResponse);
  } catch (error) {
    console.error("Upload error:", error);
    return NextResponse.json(
      { error: "Failed to upload file" },
      { status: 500 }
    );
  }
}

```

### app/api/vibe-narrative/route.ts

```typescript
import { NextResponse } from "next/server";
import Anthropic from "@anthropic-ai/sdk";
import type { VibeProfile } from "@/types";

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

export const maxDuration = 30;

export async function POST(request: Request) {
  try {
    const { vibe }: { vibe: VibeProfile } = await request.json();

    const response = await anthropic.messages.create({
      model: "claude-sonnet-4-5-20250929",
      max_tokens: 200,
      messages: [
        {
          role: "user",
          content: `You are a perceptive art critic who can see into someone's creative taste. Given this aesthetic profile, write 2-3 sentences describing what this person's eye is drawn to and why. Be specific and incisive — reference visual qualities, tensions, or cultural touchpoints. Write in second person ("You're drawn to..."). No clichés, no flattery, no flowery language. Be direct.

Color palette: ${vibe.color_palette.dominant.join(", ")}
Background tone: ${vibe.color_palette.background_tone}
Mood: ${vibe.mood_tags.join(", ")}
Aesthetic: ${vibe.aesthetic_tags.join(", ")}
Texture: ${vibe.texture}
Warmth: ${vibe.lighting.warmth}, Contrast: ${vibe.lighting.contrast}
Saturation: ${vibe.saturation}
Sonic mood: ${vibe.sonic_mood}

Return ONLY the 2-3 sentences. No quotes, no label, no explanation.`,
        },
      ],
    });

    const narrative =
      response.content[0].type === "text" ? response.content[0].text.trim() : "";

    return NextResponse.json({ narrative });
  } catch (error) {
    console.error("Vibe narrative error:", error);
    return NextResponse.json(
      { error: "Failed to generate narrative" },
      { status: 500 }
    );
  }
}

```

### app/api/music-status/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { uploadToSpaces } from "@/lib/spaces";

const SUNO_BASE = "https://studio-api.prod.suno.com/api/v2/external/hackathons";

/** Download audio from Suno CDN and re-upload to DO Spaces for permanent storage */
async function proxyAudioToSpaces(sunoUrl: string): Promise<string> {
  const res = await fetch(sunoUrl);
  if (!res.ok) throw new Error(`Failed to fetch audio: ${res.status}`);
  const buffer = Buffer.from(await res.arrayBuffer());
  const ext = sunoUrl.includes(".mp3") ? "mp3" : "wav";
  const key = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
  return uploadToSpaces(buffer, key, `audio/${ext === "mp3" ? "mpeg" : "wav"}`);
}

export async function GET(req: NextRequest) {
  const clipId = req.nextUrl.searchParams.get("id");
  if (!clipId) {
    return NextResponse.json({ error: "Missing id" }, { status: 400 });
  }

  const apiKey = process.env.SUNO_API_KEY;
  if (!apiKey) {
    return NextResponse.json({ error: "SUNO_API_KEY not configured" }, { status: 503 });
  }

  const res = await fetch(`${SUNO_BASE}/clips?ids=${clipId}`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (!res.ok) {
    return NextResponse.json({ error: "Failed to poll Suno" }, { status: res.status });
  }

  const clips = await res.json();
  const clip = Array.isArray(clips) ? clips[0] : clips;

  // Proxy completed audio to DO Spaces for permanent storage
  let audioUrl = clip?.audio_url || null;
  if (audioUrl && clip?.status === "complete") {
    try {
      audioUrl = await proxyAudioToSpaces(audioUrl);
    } catch (e) {
      console.error("Failed to proxy audio to Spaces:", e);
    }
  }

  return NextResponse.json({
    status: clip?.status || "unknown",
    audio_url: audioUrl,
    title: clip?.title || null,
    image_url: clip?.image_url || null,
  });
}

```

### app/api/oembed/route.ts

```typescript
import { NextResponse } from "next/server";

interface OEmbedResponse {
  html: string;
  title?: string;
  thumbnail_url?: string;
  provider_name?: string;
}

const OEMBED_PROVIDERS: Record<string, (url: string) => string> = {
  "youtube.com": (url) =>
    `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
  "youtu.be": (url) =>
    `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
  "open.spotify.com": (url) =>
    `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
  "vimeo.com": (url) =>
    `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`,
  "soundcloud.com": (url) =>
    `https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`,
};

function getOEmbedEndpoint(url: string): string | null {
  try {
    const hostname = new URL(url).hostname.replace(/^www\./, "");
    for (const [domain, buildUrl] of Object.entries(OEMBED_PROVIDERS)) {
      if (hostname === domain || hostname.endsWith(`.${domain}`)) {
        return buildUrl(url);
      }
    }
    return null;
  } catch {
    return null;
  }
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get("url");

  if (!url) {
    return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
  }

  const endpoint = getOEmbedEndpoint(url);
  if (!endpoint) {
    return NextResponse.json({ error: "Unsupported provider" }, { status: 404 });
  }

  try {
    const res = await fetch(endpoint, { signal: AbortSignal.timeout(5000) });
    if (!res.ok) {
      return NextResponse.json({ error: "OEmbed fetch failed" }, { status: 502 });
    }

    const data: OEmbedResponse = await res.json();
    return NextResponse.json({
      html: data.html || "",
      title: data.title || "",
      thumbnail_url: data.thumbnail_url || "",
      provider: data.provider_name || "",
    });
  } catch {
    return NextResponse.json({ error: "OEmbed request failed" }, { status: 500 });
  }
}

```

### app/api/parse-url/route.ts

```typescript
import { NextResponse } from "next/server";
import * as cheerio from "cheerio";
import anthropic from "@/lib/claude";
import type { ParseUrlResponse } from "@/types";

export const maxDuration = 30;

export async function POST(request: Request) {
  try {
    const { url } = await request.json();

    const res = await fetch(url, {
      headers: {
        "User-Agent": "Mozilla/5.0 (compatible; Patina/1.0)",
      },
    });

    if (!res.ok) {
      throw new Error(`Failed to fetch URL: ${res.status}`);
    }

    const html = await res.text();
    const $ = cheerio.load(html);

    // Extract metadata
    const title =
      $("title").text().trim() ||
      $('meta[property="og:title"]').attr("content") ||
      "";

    const description =
      $('meta[name="description"]').attr("content") ||
      $('meta[property="og:description"]').attr("content") ||
      "";

    const ogImage = $('meta[property="og:image"]').attr("content") || undefined;

    // Extract full page text — grab from semantic containers first, fall back to body
    const rawText = $("article, main, .content, .post, .entry-content, [role='main']")
      .first()
      .text()
      .replace(/\s+/g, " ")
      .trim();
    const bodyText = rawText || $("body").text().replace(/\s+/g, " ").trim();

    // Extract image URLs (first 5)
    const images: string[] = [];
    $("img").each((_, el) => {
      const src = $(el).attr("src");
      if (src && !src.startsWith("data:") && images.length < 5) {
        try {
          const resolved = new URL(src, url).href;
          images.push(resolved);
        } catch {
          // Skip invalid URLs
        }
      }
    });

    // Run through Haiku for a rich summary + sentiment/aesthetic analysis
    let text = description;
    if (bodyText.length > 100) {
      try {
        const summary = await anthropic.messages.create({
          model: "claude-haiku-4-5-20251001",
          max_tokens: 1024,
          messages: [
            {
              role: "user",
              content: `Analyze this webpage content thoroughly. Write a rich summary covering:

1. **What it's about** — the core subject, key ideas, and narrative
2. **Tone & voice** — formal/casual, technical/poetic, warm/cold, etc.
3. **Aesthetic & visual language** — any design sensibility, color references, textures, or spatial qualities described or implied
4. **Emotional register** — what feelings does this content evoke? What mood does it create?
5. **Cultural context** — references, influences, era, movement, or subculture it belongs to

End with a "Vibe:" line — a dense, evocative one-liner capturing the overall aesthetic energy (like a mood board caption).

Title: ${title}
URL: ${url}

Content:
${bodyText.slice(0, 12000)}`,
            },
          ],
        });
        text = summary.content[0].type === "text" ? summary.content[0].text : description;
      } catch (err) {
        console.error("Summary error, falling back to raw text:", err);
        text = bodyText.slice(0, 2000);
      }
    }

    return NextResponse.json({
      title,
      description,
      text,
      bodyText: bodyText.slice(0, 8000),
      images,
      ogImage,
    } satisfies ParseUrlResponse);
  } catch (error) {
    console.error("URL parse error:", error);
    return NextResponse.json(
      { error: "Failed to parse URL" },
      { status: 500 }
    );
  }
}

```

### app/api/generate-image/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
import { uploadToSpaces } from "@/lib/spaces";
import type { VibeProfile } from "@/types";

export const maxDuration = 300;

/**
 * Build a rich diffusion prompt from the composite vibe profile.
 */
function vibeToPrompt(vibe: VibeProfile): string {
  const parts: string[] = [];

  // Core mood
  parts.push(vibe.mood);

  // Aesthetic direction
  if (vibe.aesthetic_tags.length > 0) {
    parts.push(`${vibe.aesthetic_tags.join(", ")} aesthetic`);
  }

  // Color guidance
  if (vibe.color_palette.dominant.length > 0) {
    parts.push(`dominant colors: ${vibe.color_palette.dominant.join(", ")}`);
  }
  if (vibe.color_palette.accent.length > 0) {
    parts.push(`accent colors: ${vibe.color_palette.accent.join(", ")}`);
  }
  parts.push(`${vibe.color_palette.background_tone} background tone`);

  // Lighting
  const warmthDesc = vibe.lighting.warmth > 0.6 ? "warm" : vibe.lighting.warmth < 0.4 ? "cool" : "neutral";
  const contrastDesc = vibe.lighting.contrast > 0.6 ? "high contrast" : vibe.lighting.contrast < 0.4 ? "low contrast, soft" : "balanced contrast";
  parts.push(`${warmthDesc} lighting, ${contrastDesc}`);

  // Texture
  if (vibe.texture) {
    parts.push(`${vibe.texture} texture`);
  }

  // Mood tags for extra flavour
  if (vibe.mood_tags.length > 0) {
    parts.push(vibe.mood_tags.slice(0, 4).join(", "));
  }

  // Technical quality
  parts.push("masterpiece, highly detailed, professional photography, 8k");

  return parts.join(". ") + ".";
}

/**
 * Call a deployed Modal function for text-to-image generation.
 *
 * Expected Modal endpoint contract:
 *   POST <MODAL_IMAGE_GEN_URL>
 *   Headers:  Authorization: Bearer <tokenId>:<tokenSecret>
 *   Body (JSON): { prompt: string, width?: number, height?: number }
 *   Response (JSON): { image: "<base64-encoded PNG>" }
 */
async function callModal(prompt: string): Promise<Buffer> {
  const url = process.env.MODAL_IMAGE_GEN_URL || process.env.MODAL_FUNCTION_URL;
  if (!url) throw new Error("MODAL_IMAGE_GEN_URL is not configured");

  const tokenId = process.env.MODAL_TOKEN_ID;
  const tokenSecret = process.env.MODAL_TOKEN_SECRET;
  if (!tokenId || !tokenSecret)
    throw new Error("MODAL_TOKEN_ID / MODAL_TOKEN_SECRET are not configured");

  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${tokenId}:${tokenSecret}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      prompt,
      width: 1024,
      height: 1024,
    }),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "");
    throw new Error(`Modal request failed (${res.status}): ${text.slice(0, 500)}`);
  }

  const data = (await res.json()) as { image: string };
  return Buffer.from(data.image, "base64");
}

export async function POST(req: NextRequest) {
  try {
    const body = (await req.json()) as { vibe: VibeProfile; user_prompt?: string };

    if (!body.vibe) {
      return NextResponse.json({ error: "vibe is required" }, { status: 400 });
    }

    // Build prompt from vibe, optionally prepend user's custom direction
    let prompt = vibeToPrompt(body.vibe);
    if (body.user_prompt) {
      prompt = `${body.user_prompt}. ${prompt}`;
    }

    const resultBuffer = await callModal(prompt);

    // Upload to DO Spaces
    const objectKey = `generated/${randomUUID()}.png`;
    const imageUrl = await uploadToSpaces(resultBuffer, objectKey, "image/png");

    return NextResponse.json({ imageUrl, prompt });
  } catch (err) {
    console.error("[generate-image] Error:", err);
    const message = err instanceof Error ? err.message : "Internal server error";
    return NextResponse.json({ error: message }, { status: 500 });
  }
}

```

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