[object Object]

← back to Patty

initial scaffold (2026-05-06 overnight session)

244b86c2aaa82707e713f588e395cbe96947357c · 2026-05-06 10:22:13 -0700 · Steve Abrams

Files touched

Diff

commit 244b86c2aaa82707e713f588e395cbe96947357c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:22:13 2026 -0700

    initial scaffold (2026-05-06 overnight session)
---
 .gitignore                                       |   50 +
 CLAUDE.md                                        |   43 +
 README.md                                        |   36 +
 app/api/auth/login/route.ts                      |   43 +
 app/api/auth/logout/route.ts                     |    8 +
 app/api/auth/session/route.ts                    |   12 +
 app/api/campaigns/generate/route.ts              |   69 +
 app/api/campaigns/route.ts                       |  131 ++
 app/api/grants-proxy/[...path]/route.ts          |   87 +
 app/api/orbit/agents/route.ts                    |  133 ++
 app/api/orbit/batch-link/route.ts                |  168 ++
 app/api/orbit/generate/route.ts                  |  236 +++
 app/api/orbit/post/route.ts                      |  190 ++
 app/api/orbit/route.ts                           |  158 ++
 app/api/orbit/rss/route.ts                       |  295 +++
 app/api/orbit/suggest-alliance/route.ts          |  204 ++
 app/api/petitions/[slug]/post-log/route.ts       |   48 +
 app/api/petitions/[slug]/post/route.ts           |  251 +++
 app/api/petitions/generate/route.ts              |   75 +
 app/api/petitions/route.ts                       |  232 +++
 app/api/public/signatures/route.ts               |  209 ++
 app/api/pulse/[...path]/route.ts                 |   56 +
 app/api/settings/credentials/route.ts            |  146 ++
 app/api/settings/route.ts                        |   86 +
 app/api/signatures/route.ts                      |  106 +
 app/api/subscribers/route.ts                     |  176 ++
 app/api/templates/route.ts                       |   62 +
 app/api/trending/discover/route.ts               |  201 ++
 app/api/trending/route.ts                        |   69 +
 app/favicon.ico                                  |  Bin 0 -> 25931 bytes
 app/globals.css                                  |  365 ++++
 app/icon.svg                                     |    4 +
 app/layout.tsx                                   |   21 +
 app/login/page.tsx                               |  161 ++
 app/page.tsx                                     |    5 +
 app/petitions/[slug]/PostToPlatformsPanel.tsx    |  646 ++++++
 app/petitions/[slug]/ShareButtons.tsx            |  143 ++
 app/petitions/[slug]/SignatureForm.tsx           |  285 +++
 app/petitions/[slug]/page.tsx                    |  510 +++++
 components/AppShell.tsx                          |  232 +++
 components/AuthProvider.tsx                      |  132 ++
 components/Sidebar.tsx                           |  311 +++
 components/Skeleton.tsx                          |   49 +
 components/ToastProvider.tsx                     |   94 +
 components/campaigns/CampaignsTab.tsx            |  482 +++++
 components/compliance/ComplianceTab.tsx          |  213 ++
 components/dashboard/DashboardTab.tsx            |  175 ++
 components/grants/GrantsTab.tsx                  |  549 +++++
 components/grants/types.ts                       |   51 +
 components/heatmap/HeatMapTab.tsx                |  709 +++++++
 components/news/NewsFeedTab.tsx                  |  195 ++
 components/orbit/OrbitTab.tsx                    | 2382 ++++++++++++++++++++++
 components/petitions/PetitionsTab.tsx            | 1101 ++++++++++
 components/settings/SettingsTab.tsx              |  413 ++++
 components/settings/SocialCredentialsSection.tsx |  420 ++++
 components/shared/SortDropdown.tsx               |   41 +
 components/sources/SourcesTab.tsx                |  304 +++
 components/subscribers/SubscribersTab.tsx        |  446 ++++
 components/templates/TemplatesTab.tsx            |  395 ++++
 components/topics/TopicsTab.tsx                  |  415 ++++
 components/trends/TrendsTab.tsx                  |  534 +++++
 db/orbit-schema.sql                              |  122 ++
 db/orbit-seed-feeds.sql                          |   33 +
 db/schema.sql                                    |  159 ++
 ecosystem.config.js                              |   15 +
 hooks/useClientSort.ts                           |   34 +
 hooks/useDebounce.ts                             |   10 +
 lib/audit.ts                                     |   36 +
 lib/auth.ts                                      |   10 +
 lib/db.ts                                        |   45 +
 lib/gemini.ts                                    |  111 +
 lib/sanitize.ts                                  |   66 +
 lib/sister-auth.ts                               |   34 +
 middleware.ts                                    |   98 +
 next.config.ts                                   |    7 +
 package-lock.json                                | 1997 ++++++++++++++++++
 package.json                                     |   29 +
 postcss.config.mjs                               |    7 +
 public/favicon.svg                               |    4 +
 public/file.svg                                  |    1 +
 public/globe.svg                                 |    1 +
 public/next.svg                                  |    1 +
 public/vercel.svg                                |    1 +
 public/window.svg                                |    1 +
 tsconfig.json                                    |   34 +
 85 files changed, 18219 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f522159
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,50 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/versions
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+
+# env files (can opt-in for committing if needed)
+.env*
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+node_modules/
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+dist/
+build/
+*.bak
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..567c9e3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,43 @@
+# Patty - The Petition Specialist
+
+## Project Info
+- **Directory**: /root/Projects/Patty/
+- **Port**: 7460 | **PM2**: patty-petitions
+- **Database**: connection string lives in `.env.local` (DATABASE_URL); use `dw_admin@127.0.0.1:5432/patty`. NEVER commit credentials to docs.
+- **Auth**: admin / (AUTH_PASSWORD env — see .env.local; no longer hardcoded)
+- **Stack**: Next.js 16 (App Router) + Tailwind 4 + PostgreSQL
+
+## Purpose
+AI-powered petition creation, advocacy campaigns, and signature collection platform.
+Features: trending topic discovery, petition templates, email campaigns, compliance tracking.
+
+## Key Files
+- lib/db.ts -- Postgres pool
+- lib/auth.ts -- Cookie auth (patty-auth cookie)
+- lib/audit.ts -- Audit logger
+- db/schema.sql -- Full schema
+
+## Database Tables
+- users -- User accounts (Google OAuth planned)
+- petitions -- Petition content and metadata
+- signatures -- Petition signatures with dedup
+- email_subscribers -- Email list management
+- email_campaigns -- Campaign tracking
+- trending_topics -- AI-curated trending topics
+- petition_templates -- Reusable petition templates
+- audit_events -- Activity log
+
+## Design System
+- Primary: #7c3aed (violet-600)
+- Primary Hover: #6d28d9 (violet-700)
+- Secondary: #a78bfa (violet-400)
+- Dark theme with --color-bg: #0a0a12
+
+## Sidebar Tabs
+1. My Petitions (Megaphone)
+2. Trending Topics (TrendingUp)
+3. Campaigns (Mail)
+4. Subscribers (Users)
+5. Templates (FileText)
+6. Compliance (Shield)
+7. Settings (Settings)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e215bc4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+
+This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..f30e243
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,43 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createSession, buildAuthCookie } from '@/lib/auth';
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json();
+    const { username, password } = body as { username?: string; password?: string };
+
+    if (!username || !password) {
+      return NextResponse.json(
+        { error: 'Username and password are required' },
+        { status: 400 },
+      );
+    }
+
+    const expectedUsername = process.env.AUTH_USERNAME ?? 'admin';
+    const expectedPassword = process.env.AUTH_PASSWORD;
+    if (!expectedPassword) {
+      console.error('[auth/login] AUTH_PASSWORD env unset — refusing all logins (fail-closed)');
+      return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 });
+    }
+
+    if (username !== expectedUsername || password !== expectedPassword) {
+      return NextResponse.json(
+        { error: 'Invalid credentials' },
+        { status: 401 },
+      );
+    }
+
+    const token = createSession(username);
+    const cookieValue = buildAuthCookie(token);
+
+    const response = NextResponse.json({ success: true });
+    response.headers.set('Set-Cookie', cookieValue);
+    return response;
+  } catch (err) {
+    console.error('[auth/login] Error:', (err as Error).message);
+    return NextResponse.json(
+      { error: 'Internal server error' },
+      { status: 500 },
+    );
+  }
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..f304238
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from 'next/server';
+import { buildLogoutCookie } from '@/lib/auth';
+
+export async function POST() {
+  const response = NextResponse.json({ success: true });
+  response.headers.set('Set-Cookie', buildLogoutCookie());
+  return response;
+}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
new file mode 100644
index 0000000..298d70c
--- /dev/null
+++ b/app/api/auth/session/route.ts
@@ -0,0 +1,12 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const username = verifyAuth(request);
+
+  if (username) {
+    return NextResponse.json({ authenticated: true, user: username });
+  }
+
+  return NextResponse.json({ authenticated: false }, { status: 401 });
+}
diff --git a/app/api/campaigns/generate/route.ts b/app/api/campaigns/generate/route.ts
new file mode 100644
index 0000000..5ef1488
--- /dev/null
+++ b/app/api/campaigns/generate/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+type CampaignShape = { subject?: string; body_html?: string; body_text?: string };
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { petition_id, tone, call_to_action_type } = body;
+
+    let petitionContext = '';
+    if (petition_id) {
+      const petResult = await query(`SELECT title, summary, body_text, target, signature_count, signature_goal FROM petitions WHERE id = $1`, [petition_id]);
+      if (petResult.rows.length > 0) {
+        const p = petResult.rows[0];
+        petitionContext = `
+Petition Title: ${p.title}
+Petition Summary: ${p.summary || 'N/A'}
+Petition Target: ${p.target || 'N/A'}
+Current Signatures: ${p.signature_count} / ${p.signature_goal} goal
+Petition Body: ${(p.body_text || '').slice(0, 500)}`;
+      }
+    }
+
+    const toneDesc = tone || 'urgent';
+    const ctaType = call_to_action_type || 'sign';
+
+    const prompt = `You are an expert email copywriter for advocacy campaigns about student debt and education. Generate a compelling campaign email.
+
+${petitionContext ? `PETITION CONTEXT:${petitionContext}` : 'Generate a general student debt advocacy email.'}
+
+Tone: ${toneDesc}
+Call to Action: ${ctaType === 'sign' ? 'Sign the petition' : ctaType === 'share' ? 'Share with friends' : ctaType === 'donate' ? 'Support the cause' : 'Take action'}
+
+Return a JSON object with EXACTLY these fields:
+{
+  "subject": "Compelling email subject line (max 80 chars, no clickbait)",
+  "body_html": "<div style='font-family: sans-serif; max-width: 600px; margin: 0 auto;'>Full HTML email content with inline styles. Include greeting, context, emotional hook, call to action button, and signature. Use <a>, <strong>, <p>, <h2> tags. Include a prominent CTA button styled as: <a href='#sign' style='display:inline-block;padding:12px 32px;background:#7c3aed;color:white;text-decoration:none;border-radius:8px;font-weight:bold;'>Sign Now</a></div>",
+  "body_text": "Plain text version of the email for non-HTML clients"
+}
+
+Make it personal, urgent, and compelling. Reference specific statistics about student debt where relevant.`;
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<CampaignShape>({ prompt, maxTokens: 2048, temperature: 0.8 });
+    if (!r.ok) {
+      console.error('[campaigns/generate] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+        { status: r.status },
+      );
+    }
+    const parsed = r.data;
+
+    return NextResponse.json({
+      subject: parsed.subject || '',
+      body_html: parsed.body_html || '',
+      body_text: parsed.body_text || '',
+    });
+  } catch (err) {
+    console.error('[api/campaigns/generate] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to generate campaign content' }, { status: 500 });
+  }
+}
diff --git a/app/api/campaigns/route.ts b/app/api/campaigns/route.ts
new file mode 100644
index 0000000..aba33a4
--- /dev/null
+++ b/app/api/campaigns/route.ts
@@ -0,0 +1,131 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const status = url.searchParams.get('status');
+
+  let sql = `SELECT c.*, p.title as petition_title
+             FROM email_campaigns c
+             LEFT JOIN petitions p ON c.petition_id = p.id`;
+  const params: unknown[] = [];
+
+  if (status && status !== 'all') {
+    params.push(status);
+    sql += ` WHERE c.status = $${params.length}`;
+  }
+
+  sql += ` ORDER BY c.created_at DESC`;
+
+  try {
+    const result = await query(sql, params);
+
+    // Get status counts
+    const countsResult = await query(
+      `SELECT status, COUNT(*)::int as count FROM email_campaigns GROUP BY status`
+    );
+    const statusCounts: Record<string, number> = { draft: 0, scheduled: 0, sending: 0, sent: 0, failed: 0 };
+    for (const row of countsResult.rows) {
+      statusCounts[row.status] = row.count;
+    }
+    statusCounts.all = Object.values(statusCounts).reduce((a, b) => a + b, 0);
+
+    return NextResponse.json({ rows: result.rows, statusCounts });
+  } catch (err) {
+    console.error('[api/campaigns] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch campaigns' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { subject, body_html, body_text, petition_id, send_to, scheduled_at } = body;
+
+    if (!subject || !body_html) {
+      return NextResponse.json({ error: 'Subject and body_html are required' }, { status: 400 });
+    }
+
+    // Get user_id
+    const userResult = await query(`SELECT id FROM users LIMIT 1`);
+    const userId = userResult.rows[0]?.id;
+    if (!userId) {
+      return NextResponse.json({ error: 'No user found' }, { status: 500 });
+    }
+
+    // Count subscribers for recipient_count
+    const subCount = await query(`SELECT COUNT(*)::int as count FROM email_subscribers WHERE is_active = true`);
+    const recipientCount = subCount.rows[0]?.count || 0;
+
+    const result = await query(
+      `INSERT INTO email_campaigns (user_id, petition_id, subject, body_html, body_text, send_to, recipient_count, scheduled_at, status)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+       RETURNING *`,
+      [
+        userId,
+        petition_id || null,
+        subject,
+        body_html,
+        body_text || null,
+        send_to || 'all',
+        recipientCount,
+        scheduled_at || null,
+        scheduled_at ? 'scheduled' : 'draft',
+      ]
+    );
+
+    return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/campaigns] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create campaign' }, { status: 500 });
+  }
+}
+
+export async function PATCH(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { id, ...updates } = body;
+
+    if (!id) {
+      return NextResponse.json({ error: 'Campaign id is required' }, { status: 400 });
+    }
+
+    const allowedFields = ['subject', 'body_html', 'body_text', 'status', 'send_to', 'scheduled_at', 'sent_at', 'recipient_count', 'open_count', 'click_count'];
+    const setClauses: string[] = [];
+    const params: unknown[] = [];
+
+    for (const [key, value] of Object.entries(updates)) {
+      if (allowedFields.includes(key)) {
+        params.push(value);
+        setClauses.push(`${key} = $${params.length}`);
+      }
+    }
+
+    if (setClauses.length === 0) {
+      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+    }
+
+    params.push(id);
+    const sql = `UPDATE email_campaigns SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+    const result = await query(sql, params);
+    if (result.rows.length === 0) {
+      return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ row: result.rows[0] });
+  } catch (err) {
+    console.error('[api/campaigns] PATCH error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to update campaign' }, { status: 500 });
+  }
+}
diff --git a/app/api/grants-proxy/[...path]/route.ts b/app/api/grants-proxy/[...path]/route.ts
new file mode 100644
index 0000000..084a429
--- /dev/null
+++ b/app/api/grants-proxy/[...path]/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { sisterCredentials } from '@/lib/sister-auth';
+
+const GRANT_BASE = 'http://127.0.0.1:7450';
+
+// Module-scoped session cache for Grant app cookie auth
+let grantCookie: string | null = null;
+let cookieExpiry = 0;
+
+async function getGrantAuth(): Promise<string> {
+  if (grantCookie && Date.now() < cookieExpiry) return grantCookie;
+
+  // Sister-service login creds from env (audit 2026-05-04: removed inline literal).
+  const res = await fetch(`${GRANT_BASE}/api/auth/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(sisterCredentials()),
+  });
+
+  const setCookie = res.headers.get('set-cookie') || '';
+  const match = setCookie.match(/grant-auth=([^;]+)/);
+  if (!match) throw new Error('Grant auth failed');
+
+  grantCookie = `grant-auth=${match[1]}`;
+  cookieExpiry = Date.now() + 23 * 60 * 60 * 1000; // 23h
+  return grantCookie;
+}
+
+async function proxyToGrant(request: NextRequest, params: Promise<{ path: string[] }>, method: string) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const { path } = await params;
+  const pathStr = path.join('/');
+  const url = new URL(request.url);
+  const qs = url.search;
+
+  try {
+    let cookie = await getGrantAuth();
+    const headers: Record<string, string> = { Cookie: cookie };
+    let body: string | undefined;
+
+    if (method === 'POST' || method === 'PATCH') {
+      headers['Content-Type'] = 'application/json';
+      body = await request.text();
+    }
+
+    let res = await fetch(`${GRANT_BASE}/api/${pathStr}${qs}`, {
+      method, headers, body,
+      signal: AbortSignal.timeout(30000),
+    });
+
+    // Re-auth on 401
+    if (res.status === 401) {
+      grantCookie = null;
+      cookieExpiry = 0;
+      cookie = await getGrantAuth();
+      headers.Cookie = cookie;
+      res = await fetch(`${GRANT_BASE}/api/${pathStr}${qs}`, {
+        method, headers, body,
+        signal: AbortSignal.timeout(30000),
+      });
+    }
+
+    const data = await res.json();
+    return NextResponse.json(data, { status: res.status });
+  } catch {
+    return NextResponse.json({ error: 'Grant service unavailable' }, { status: 502 });
+  }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToGrant(req, params, 'GET');
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToGrant(req, params, 'POST');
+}
+
+export async function PATCH(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToGrant(req, params, 'PATCH');
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToGrant(req, params, 'DELETE');
+}
diff --git a/app/api/orbit/agents/route.ts b/app/api/orbit/agents/route.ts
new file mode 100644
index 0000000..a253060
--- /dev/null
+++ b/app/api/orbit/agents/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const AGENTS = [
+  { name: 'Phoebe', role: 'Figma QA', port: 9668, health: '/health' },
+  { name: 'Sandy', role: 'Scene Assembler', port: 9652, health: '/health' },
+  { name: 'Sherman', role: 'Material Librarian', port: 9650, health: '/health' },
+  { name: 'Sheldon', role: 'Performance Auditor', port: 9651, health: '/health' },
+  { name: 'Shane', role: 'Asset Validator', port: 9653, health: '/health' },
+  { name: 'Graphic', role: 'Chart Generator', port: 9846, health: '/health' },
+  { name: 'Pulse', role: 'News Intelligence', port: 9845, health: '/health' },
+];
+
+async function checkAgent(agent: typeof AGENTS[0]): Promise<{ name: string; role: string; port: number; status: string; uptime?: number }> {
+  try {
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 3000);
+    const res = await fetch(`http://127.0.0.1:${agent.port}${agent.health}`, {
+      signal: controller.signal,
+      headers: { 'Authorization': SISTER_AUTH },
+    });
+    clearTimeout(timeout);
+    if (res.ok) {
+      const data = await res.json().catch(() => ({}));
+      return { name: agent.name, role: agent.role, port: agent.port, status: 'online', uptime: data.uptime };
+    }
+    return { name: agent.name, role: agent.role, port: agent.port, status: 'error' };
+  } catch {
+    return { name: agent.name, role: agent.role, port: agent.port, status: 'offline' };
+  }
+}
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  if (!SISTER_OK) return sisterUnconfigured();
+
+  const results = await Promise.all(AGENTS.map(checkAgent));
+  const online = results.filter(r => r.status === 'online').length;
+
+  return NextResponse.json({
+    agents: results,
+    summary: { total: AGENTS.length, online, offline: AGENTS.length - online },
+  });
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  if (!SISTER_OK) return sisterUnconfigured();
+
+  try {
+    const body = await request.json();
+    const { action, agent_name } = body as { action: string; agent_name?: string };
+
+    if (action === 'screenshot' || action === 'phoebe/screenshot') {
+      const res = await fetch('http://127.0.0.1:9668/api/screenshot', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': SISTER_AUTH,
+        },
+        body: JSON.stringify({ url: 'http://45.61.58.125:7460', viewport: { width: 1920, height: 1080 } }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        return NextResponse.json({ success: true, action: 'screenshot', data });
+      }
+      return NextResponse.json({ error: 'Screenshot failed' }, { status: 502 });
+    }
+
+    if (action === 'gif' || action === 'phoebe/gif') {
+      const res = await fetch('http://127.0.0.1:9668/api/record-gif', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': SISTER_AUTH,
+        },
+        body: JSON.stringify({ url: 'http://45.61.58.125:7460', frames: 15, delay: 500 }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        return NextResponse.json({ success: true, action: 'gif', data });
+      }
+      return NextResponse.json({ error: 'GIF recording failed' }, { status: 502 });
+    }
+
+    if (action === 'perf' || action === 'sheldon/audit') {
+      const res = await fetch('http://127.0.0.1:9651/api/perf/audit', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'Authorization': SISTER_AUTH,
+        },
+      });
+      if (res.ok) {
+        const data = await res.json();
+        return NextResponse.json({ success: true, action: 'perf_audit', data });
+      }
+      return NextResponse.json({ error: 'Performance audit failed' }, { status: 502 });
+    }
+
+    if (action === 'chart/signatures') {
+      const { petition_id } = body;
+      if (!petition_id) return NextResponse.json({ error: 'petition_id required' }, { status: 400 });
+      const res = await fetch(`http://127.0.0.1:9846/api/chart/signature-progress?petition_id=${petition_id}`, {
+        headers: { 'Authorization': SISTER_AUTH },
+      });
+      if (res.ok) {
+        const svg = await res.text();
+        return new NextResponse(svg, { headers: { 'Content-Type': 'image/svg+xml' } });
+      }
+      return NextResponse.json({ error: 'Chart generation failed' }, { status: 502 });
+    }
+
+    if (action === 'chart/sentiment') {
+      const res = await fetch('http://127.0.0.1:9846/api/chart/sentiment-map', {
+        headers: { 'Authorization': SISTER_AUTH },
+      });
+      if (res.ok) {
+        const svg = await res.text();
+        return new NextResponse(svg, { headers: { 'Content-Type': 'image/svg+xml' } });
+      }
+      return NextResponse.json({ error: 'Sentiment map failed' }, { status: 502 });
+    }
+
+    return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
+  } catch (err) {
+    console.error('[api/orbit/agents] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Agent action failed' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/batch-link/route.ts b/app/api/orbit/batch-link/route.ts
new file mode 100644
index 0000000..0b092c5
--- /dev/null
+++ b/app/api/orbit/batch-link/route.ts
@@ -0,0 +1,168 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    // Fetch top markets, petitions, and trending topics
+    const [marketsRes, petitionsRes, trendingRes] = await Promise.all([
+      query(`SELECT id, ticker, title, category, yes_bid, volume, petition_relevance
+             FROM orbit_markets ORDER BY volume DESC NULLS LAST LIMIT 25`),
+      query(`SELECT id, title, category, status FROM petitions ORDER BY created_at DESC LIMIT 20`),
+      query(`SELECT id, title, content, category, tags FROM trending_topics ORDER BY engagement_score DESC LIMIT 15`),
+    ]);
+
+    const markets = marketsRes.rows;
+    const petitions = petitionsRes.rows;
+    const trending = trendingRes.rows;
+
+    if (markets.length === 0) {
+      return NextResponse.json({ error: 'No markets available to link' }, { status: 400 });
+    }
+
+    const marketSummary = markets.map((m, i) =>
+      `M${i + 1}. [${m.ticker}] "${m.title}" (${m.category}, ${m.yes_bid}% yes, vol=${m.volume})`
+    ).join('\n');
+
+    const petitionSummary = petitions.length > 0
+      ? petitions.map((p, i) => `P${i + 1}. "${p.title}" (${p.category}, ${p.status})`).join('\n')
+      : 'No petitions yet.';
+
+    const trendingSummary = trending.length > 0
+      ? trending.map((t, i) => `T${i + 1}. "${t.title}" — ${(t.content || '').substring(0, 100)}`).join('\n')
+      : 'No trending topics.';
+
+    const prompt = `You are an AI analyst connecting prediction markets, petitions, and trending topics for an advocacy platform.
+
+MARKETS:
+${marketSummary}
+
+PETITIONS:
+${petitionSummary}
+
+TRENDING TOPICS:
+${trendingSummary}
+
+Generate connections between these items. For each connection, provide:
+1. market_index (M1, M2...) and petition_index (P1, P2...) OR trending_index (T1, T2...)
+2. link_type: "influence" (market affects petition), "counter" (opposing), "support" (aligned), or "alliance" (unexpected connection)
+3. strength: 0.0 to 1.0 (how strong is the connection)
+4. ai_reasoning: one sentence explaining the connection
+
+Also suggest NEW petition ideas from trending topics that don't have petitions yet.
+
+Return a JSON object:
+{
+  "links": [
+    { "market_idx": "M1", "petition_idx": "P1", "link_type": "influence", "strength": 0.8, "ai_reasoning": "..." },
+    { "market_idx": "M3", "trending_idx": "T2", "link_type": "support", "strength": 0.7, "ai_reasoning": "..." }
+  ],
+  "suggested_petitions": [
+    { "trending_idx": "T1", "title": "...", "summary": "...", "suggested_target": "...", "category": "..." }
+  ]
+}
+
+Generate 15-25 links and 3-5 petition suggestions. Only include strong, meaningful connections (strength > 0.4).
+Return ONLY valid JSON, no markdown.`;
+
+    type BatchLinkResult = {
+      links: Array<{
+        market_idx: string;
+        petition_idx?: string;
+        trending_idx?: string;
+        link_type: string;
+        strength: number;
+        ai_reasoning: string;
+      }>;
+      suggested_petitions: Array<{
+        trending_idx: string;
+        title: string;
+        summary: string;
+        suggested_target: string;
+        category: string;
+      }>;
+    };
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<BatchLinkResult>({ prompt, maxTokens: 4096, temperature: 0.7 });
+    if (!r.ok) {
+      console.error('[batch-link] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI analysis failed' },
+        { status: r.status },
+      );
+    }
+    const result = r.data;
+
+    // Insert market-petition links
+    let linksInserted = 0;
+    for (const link of (result.links || [])) {
+      const mIdx = parseInt(link.market_idx?.replace('M', '')) - 1;
+      const market = markets[mIdx];
+      if (!market) continue;
+
+      // Link to petition
+      if (link.petition_idx) {
+        const pIdx = parseInt(link.petition_idx.replace('P', '')) - 1;
+        const petition = petitions[pIdx];
+        if (!petition) continue;
+
+        try {
+          await query(`
+            INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+            VALUES ($1, $2, $3, $4, $5)
+            ON CONFLICT (market_id, petition_id) DO UPDATE SET
+              link_type = $3, strength = $4, ai_reasoning = $5
+          `, [market.id, petition.id, link.link_type || 'influence', link.strength || 0.5, link.ai_reasoning || '']);
+          linksInserted++;
+        } catch (e) {
+          console.error('[api/orbit/batch-link] Link insert error:', (e as Error).message);
+        }
+      }
+    }
+
+    // Create suggested petitions from trending topics
+    let petitionsCreated = 0;
+    for (const sp of (result.suggested_petitions || [])) {
+      const tIdx = parseInt(sp.trending_idx?.replace('T', '')) - 1;
+      const topic = trending[tIdx];
+      if (!topic || !sp.title) continue;
+
+      try {
+        await query(`
+          INSERT INTO petitions (title, summary, category, status, target_name)
+          VALUES ($1, $2, $3, 'draft', $4)
+        `, [sp.title, sp.summary || '', sp.category || 'policy', sp.suggested_target || '']);
+        petitionsCreated++;
+      } catch (e) {
+        console.error('[api/orbit/batch-link] Petition insert error:', (e as Error).message);
+      }
+    }
+
+    // Update petition_relevance on markets
+    for (const link of (result.links || [])) {
+      const mIdx = parseInt(link.market_idx?.replace('M', '')) - 1;
+      const market = markets[mIdx];
+      if (!market) continue;
+      const relevance = Math.round((link.strength || 0.5) * 100);
+      try {
+        await query(`UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, $1) WHERE id = $2`,
+          [relevance, market.id]);
+      } catch { /* ignore */ }
+    }
+
+    return NextResponse.json({
+      links_inserted: linksInserted,
+      petitions_created: petitionsCreated,
+      total_links_analyzed: (result.links || []).length,
+      total_suggestions: (result.suggested_petitions || []).length,
+    });
+  } catch (err) {
+    console.error('[api/orbit/batch-link] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to generate links' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/generate/route.ts b/app/api/orbit/generate/route.ts
new file mode 100644
index 0000000..93fbab1
--- /dev/null
+++ b/app/api/orbit/generate/route.ts
@@ -0,0 +1,236 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/orbit/generate
+ * AI-generate a petition from a Kalshi prediction market OR a Pulse topic title.
+ * Accepts either { market_ticker } or { topic_title } in the body.
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { market_ticker, topic_title, tone, category } = body;
+
+    if (!market_ticker && !topic_title) {
+      return NextResponse.json({ error: 'market_ticker or topic_title is required' }, { status: 400 });
+    }
+
+    const toneDesc = tone || 'urgent';
+    let prompt: string;
+    let market: any = null;
+    let aiSource: string;
+    let categoryDesc: string;
+    let aiTopicData: Record<string, unknown>;
+
+    if (market_ticker) {
+      // ── Market-based generation (original path) ──
+      const marketRes = await query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_ticker]);
+      if (marketRes.rows.length === 0) {
+        return NextResponse.json({ error: 'Market not found. Sync markets first via GET /api/orbit' }, { status: 404 });
+      }
+      market = marketRes.rows[0];
+      categoryDesc = category || market.category || 'policy';
+      aiSource = 'kalshi';
+      aiTopicData = {
+        market_ticker: market.ticker,
+        market_title: market.title,
+        market_category: market.category,
+        yes_bid: market.yes_bid,
+        last_price: market.last_price,
+        volume: market.volume,
+        generated_at: new Date().toISOString(),
+      };
+
+      prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition inspired by a Kalshi prediction market.
+
+PREDICTION MARKET DATA:
+- Ticker: ${market.ticker}
+- Title: ${market.title}
+- Subtitle: ${market.subtitle || 'N/A'}
+- Category: ${market.category || 'General'}
+- Current YES price: ${market.yes_bid || 'N/A'}c (bid) / ${market.yes_ask || 'N/A'}c (ask)
+- Current NO price: ${market.no_bid || 'N/A'}c (bid) / ${market.no_ask || 'N/A'}c (ask)
+- Last trade price: ${market.last_price || 'N/A'}c
+- Volume: ${market.volume || 'N/A'} contracts
+- Open Interest: ${market.open_interest || 'N/A'}
+
+The prediction market prices represent the market's assessment of the probability of this event occurring (price in cents = probability percentage).
+
+PETITION REQUIREMENTS:
+- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+- Category: ${categoryDesc}
+- The petition should advocate for a specific policy action or demand related to the market's subject
+- Use the market probability to frame urgency (e.g., if market says 80% chance of something bad, that urgency should be reflected)
+- Do NOT mention Kalshi, prediction markets, or betting in the petition text itself
+- Make it feel like a real grassroots petition
+
+Return a JSON object with EXACTLY these fields:
+{
+  "title": "Compelling petition title (max 100 chars)",
+  "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
+  "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+  "body_text": "Plain text version of the body",
+  "suggested_target": "The specific person, office, or body to address this petition to",
+  "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
+  "suggested_goal": 1000,
+  "influence_analysis": "2-3 sentences explaining how this petition, if successful, could influence the outcome that the prediction market is tracking. Be specific about the causal chain."
+}`;
+    } else {
+      // ── Topic-based generation (from Pulse of America) ──
+      categoryDesc = category || 'policy';
+      aiSource = 'pulse';
+      aiTopicData = {
+        topic_title: topic_title,
+        source: 'pulse-agent',
+        generated_at: new Date().toISOString(),
+      };
+
+      prompt = `You are a professional petition writer for a civic engagement platform. Your task is to create a compelling petition based on a trending news topic.
+
+TRENDING TOPIC:
+"${topic_title}"
+
+This topic was identified by our Pulse of America news intelligence system as a high-interest issue currently in the public discourse.
+
+PETITION REQUIREMENTS:
+- Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+- Category: ${categoryDesc}
+- The petition should advocate for a specific policy action or demand related to this topic
+- Frame urgency based on the current news cycle — this is trending NOW
+- Make it feel like a real grassroots petition that a concerned citizen would write
+- Include specific, actionable demands (not vague calls for "awareness")
+
+Return a JSON object with EXACTLY these fields:
+{
+  "title": "Compelling petition title (max 100 chars)",
+  "summary": "2-3 sentence hook that motivates signatures (max 300 chars)",
+  "body_html": "<p>Full petition body in HTML. 4-6 paragraphs. Include specific demands, context, urgency, and a call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+  "body_text": "Plain text version of the body",
+  "suggested_target": "The specific person, office, or body to address this petition to",
+  "suggested_tags": ["array", "of", "3-5", "relevant", "tags"],
+  "suggested_goal": 5000,
+  "influence_analysis": "2-3 sentences explaining the potential impact of this petition on the issue. Be specific about what policy change or public outcome it could drive."
+}`;
+    }
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<Record<string, unknown>>({ prompt, maxTokens: 4096, temperature: 0.8, timeoutMs: 30_000 });
+    if (!r.ok) {
+      console.error('[orbit/generate] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+        { status: r.status },
+      );
+    }
+    const parsed = r.data as Record<string, unknown>;
+
+    // Create the petition in the petitions table
+    const fallbackTitle = market ? market.title : topic_title;
+    const title = String(parsed.title || `Petition: ${fallbackTitle}`);
+    let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+
+    // Check for duplicate slug
+    const existingSlug = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+    if (existingSlug.rows.length > 0) {
+      slug = `${slug}-${Date.now().toString(36)}`;
+    }
+
+    // Get user_id
+    const userResult = await query(`SELECT id FROM users LIMIT 1`);
+    const userId = userResult.rows[0]?.id;
+    if (!userId) {
+      return NextResponse.json({ error: 'No user found in system' }, { status: 500 });
+    }
+
+    const petitionResult = await query(
+      `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, category, tags,
+        signature_goal, ai_source, ai_topic_data, status)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+       RETURNING *`,
+      [
+        userId,
+        title,
+        slug,
+        parsed.summary || null,
+        parsed.body_html || '<p>Generated petition content</p>',
+        parsed.body_text || null,
+        parsed.suggested_target || null,
+        categoryDesc,
+        // Normalize tags: ensure clean string array for TEXT[] column
+        Array.isArray(parsed.suggested_tags)
+          ? parsed.suggested_tags.map((t: string) => String(t).replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
+          : typeof parsed.suggested_tags === 'string'
+            ? parsed.suggested_tags.replace(/^\[|\]$/g, '').split(',').map((t: string) => t.replace(/^["'{]+|["'}]+$/g, '').trim()).filter(Boolean)
+            : null,
+        parsed.suggested_goal || 5000,
+        aiSource,
+        JSON.stringify(aiTopicData),
+        'draft',
+      ],
+    );
+    const petition = petitionResult.rows[0];
+
+    // Create orbit_link if we have a market
+    let link = null;
+    if (market) {
+      const linkResult = await query(
+        `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+         VALUES ($1, $2, $3, $4, $5)
+         ON CONFLICT (market_id, petition_id) DO UPDATE SET
+           ai_reasoning = EXCLUDED.ai_reasoning,
+           strength = EXCLUDED.strength
+         RETURNING *`,
+        [
+          market.id,
+          petition.id,
+          'influence',
+          0.8,
+          parsed.influence_analysis || 'AI-generated petition from prediction market data.',
+        ],
+      );
+      link = linkResult.rows[0];
+
+      // Update market petition_relevance and suggested_petition
+      await query(
+        `UPDATE orbit_markets SET
+          petition_relevance = GREATEST(petition_relevance, 75),
+          suggested_petition = $2,
+          suggested_target = $3
+         WHERE id = $1`,
+        [market.id, title, parsed.suggested_target || null],
+      );
+    }
+
+    await auditLog('orbit.petition_generated', 'petition', petition.id, {
+      source: aiSource,
+      market_ticker: market?.ticker || null,
+      topic_title: topic_title || null,
+      tone: toneDesc,
+      category: categoryDesc,
+    });
+
+    return NextResponse.json({
+      petition,
+      link,
+      influence_analysis: parsed.influence_analysis || '',
+      source: aiSource,
+      ...(market ? {
+        market: {
+          ticker: market.ticker,
+          title: market.title,
+          category: market.category,
+          last_price: market.last_price,
+        },
+      } : {}),
+    });
+  } catch (err) {
+    console.error('[api/orbit/generate] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to generate petition from market' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/post/route.ts b/app/api/orbit/post/route.ts
new file mode 100644
index 0000000..ebe5d0a
--- /dev/null
+++ b/app/api/orbit/post/route.ts
@@ -0,0 +1,190 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+// Platform creation URLs and formatting templates
+const PLATFORM_CONFIG: Record<string, { creation_url: string; name: string }> = {
+  moveon: {
+    creation_url: 'https://sign.moveon.org/petitions/new',
+    name: 'MoveOn',
+  },
+  change_org: {
+    creation_url: 'https://www.change.org/start-a-petition',
+    name: 'Change.org',
+  },
+  we_the_people: {
+    creation_url: 'https://petitions.whitehouse.gov/petition/create',
+    name: 'We The People',
+  },
+  custom: {
+    creation_url: '',
+    name: 'Custom Platform',
+  },
+};
+
+/**
+ * Format petition content for MoveOn
+ */
+function formatForMoveOn(petition: any): Record<string, unknown> {
+  return {
+    title: petition.title,
+    target: petition.target || 'U.S. Congress',
+    description: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+    category: petition.category || 'Other',
+    // MoveOn-specific fields
+    why_important: petition.summary || '',
+    suggested_tags: petition.tags || [],
+    signature_goal: petition.signature_goal || 1000,
+  };
+}
+
+/**
+ * Format petition content for Change.org
+ */
+function formatForChangeOrg(petition: any): Record<string, unknown> {
+  return {
+    title: petition.title,
+    decision_maker: petition.target || 'Congress',
+    petition_body: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+    // Change.org uses different field names
+    letter_body: petition.body_html || '',
+    tags: petition.tags || [],
+    category: petition.category || 'Government & Politics',
+    goal: petition.signature_goal || 1000,
+    image_url: petition.image_url || null,
+  };
+}
+
+/**
+ * Format petition content for We The People
+ */
+function formatForWeThePeople(petition: any): Record<string, unknown> {
+  // We The People has a 800-character limit for the body
+  const bodyText = petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '';
+  return {
+    title: petition.title.substring(0, 120),
+    body: bodyText.substring(0, 800),
+    // Must be addressed to the Administration
+    issues: petition.tags?.slice(0, 3) || [],
+  };
+}
+
+/**
+ * GET /api/orbit/post
+ * List cross-platform posts for a petition
+ */
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const url = new URL(request.url);
+    const petitionId = url.searchParams.get('petition_id');
+
+    let sql = `SELECT op.*, p.title as petition_title
+               FROM orbit_posts op
+               JOIN petitions p ON op.petition_id = p.id`;
+    const params: unknown[] = [];
+
+    if (petitionId) {
+      params.push(petitionId);
+      sql += ` WHERE op.petition_id = $1`;
+    }
+
+    sql += ` ORDER BY op.created_at DESC`;
+    const result = await query(sql, params);
+
+    return NextResponse.json({ posts: result.rows });
+  } catch (err) {
+    console.error('[api/orbit/post] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 });
+  }
+}
+
+/**
+ * POST /api/orbit/post
+ * Create a cross-platform petition post
+ * Since we cannot directly POST to MoveOn/Change.org APIs (requires OAuth/partner access),
+ * we generate formatted content and provide a creation URL + copy-ready content.
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { petition_id, platform, custom_url } = body;
+
+    if (!petition_id || !platform) {
+      return NextResponse.json({ error: 'petition_id and platform are required' }, { status: 400 });
+    }
+
+    const validPlatforms = Object.keys(PLATFORM_CONFIG);
+    if (!validPlatforms.includes(platform)) {
+      return NextResponse.json(
+        { error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}` },
+        { status: 400 },
+      );
+    }
+
+    // Fetch the petition
+    const petitionRes = await query(`SELECT * FROM petitions WHERE id = $1`, [petition_id]);
+    if (petitionRes.rows.length === 0) {
+      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+    }
+    const petition = petitionRes.rows[0];
+
+    // Generate platform-specific formatted content
+    let formattedContent: Record<string, unknown>;
+    switch (platform) {
+      case 'moveon':
+        formattedContent = formatForMoveOn(petition);
+        break;
+      case 'change_org':
+        formattedContent = formatForChangeOrg(petition);
+        break;
+      case 'we_the_people':
+        formattedContent = formatForWeThePeople(petition);
+        break;
+      case 'custom':
+        formattedContent = {
+          title: petition.title,
+          body: petition.body_text || petition.body_html?.replace(/<[^>]+>/g, '') || '',
+          target: petition.target,
+          url: custom_url || '',
+        };
+        break;
+      default:
+        formattedContent = { title: petition.title };
+    }
+
+    const platformCfg = PLATFORM_CONFIG[platform];
+    const externalUrl = custom_url || platformCfg.creation_url;
+
+    // Save the post record
+    const result = await query(
+      `INSERT INTO orbit_posts (petition_id, platform, external_url, status, post_data)
+       VALUES ($1, $2, $3, $4, $5)
+       RETURNING *`,
+      [petition_id, platform, externalUrl, 'draft', JSON.stringify(formattedContent)],
+    );
+
+    await auditLog('orbit.post_created', 'orbit_post', result.rows[0].id, {
+      petition_id,
+      platform,
+      petition_title: petition.title,
+    });
+
+    return NextResponse.json({
+      post: result.rows[0],
+      creation_url: externalUrl,
+      platform_name: platformCfg.name,
+      formatted_content: formattedContent,
+      instructions: `Copy the formatted content below and paste it into the ${platformCfg.name} petition creation form at: ${externalUrl}`,
+    });
+  } catch (err) {
+    console.error('[api/orbit/post] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create post' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/route.ts b/app/api/orbit/route.ts
new file mode 100644
index 0000000..187669c
--- /dev/null
+++ b/app/api/orbit/route.ts
@@ -0,0 +1,158 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+
+const KEN_API = 'http://127.0.0.1:7810/api/markets';
+
+/**
+ * GET /api/orbit
+ * Orbit dashboard: sync Kalshi markets, return markets + petitions + links + stats
+ */
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    // 1. Fetch fresh markets from Ken/Kalshi agent
+    let kenMarkets: any[] = [];
+    try {
+      const kenRes = await fetch(KEN_API, { signal: AbortSignal.timeout(8000) });
+      if (kenRes.ok) {
+        const kenData = await kenRes.json();
+        kenMarkets = kenData.markets || [];
+      } else {
+        console.warn('[orbit] Ken API returned', kenRes.status);
+      }
+    } catch (err) {
+      console.warn('[orbit] Ken API unreachable:', (err as Error).message);
+    }
+
+    // 2. Upsert markets into orbit_markets
+    let upsertCount = 0;
+    for (const m of kenMarkets) {
+      try {
+        await query(
+          `INSERT INTO orbit_markets (ticker, event_ticker, title, subtitle, category,
+            yes_bid, yes_ask, no_bid, no_ask, last_price, volume, volume_24h, open_interest,
+            raw_data, last_synced_at)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
+           ON CONFLICT (ticker) DO UPDATE SET
+             event_ticker = EXCLUDED.event_ticker,
+             title = EXCLUDED.title,
+             subtitle = EXCLUDED.subtitle,
+             category = EXCLUDED.category,
+             yes_bid = EXCLUDED.yes_bid,
+             yes_ask = EXCLUDED.yes_ask,
+             no_bid = EXCLUDED.no_bid,
+             no_ask = EXCLUDED.no_ask,
+             last_price = EXCLUDED.last_price,
+             volume = EXCLUDED.volume,
+             volume_24h = EXCLUDED.volume_24h,
+             open_interest = EXCLUDED.open_interest,
+             raw_data = EXCLUDED.raw_data,
+             last_synced_at = NOW(),
+             updated_at = NOW()`,
+          [
+            m.ticker,
+            m.event_ticker || null,
+            m.title,
+            m.subtitle || null,
+            m.category || null,
+            m.yes_bid ?? null,
+            m.yes_ask ?? null,
+            m.no_bid ?? null,
+            m.no_ask ?? null,
+            m.last_price ?? null,
+            m.volume ? parseInt(m.volume, 10) : null,
+            m.volume_24h ? parseInt(m.volume_24h, 10) : null,
+            m.open_interest ? parseInt(m.open_interest, 10) : null,
+            JSON.stringify(m),
+          ],
+        );
+        upsertCount++;
+      } catch (err) {
+        console.error('[orbit] Upsert failed for', m.ticker, (err as Error).message);
+      }
+    }
+
+    // 3. Fetch data for response
+    const [marketsRes, petitionsRes, linksRes, feedsRes, articlesRes, trendingRes] = await Promise.all([
+      query(`SELECT * FROM orbit_markets ORDER BY petition_relevance DESC, last_synced_at DESC LIMIT 200`),
+      query(`SELECT id, title, slug, status, category, signature_count, signature_goal, ai_source, created_at
+             FROM petitions ORDER BY created_at DESC LIMIT 50`),
+      query(`SELECT ol.*, om.ticker, om.title as market_title, p.title as petition_title
+             FROM orbit_links ol
+             JOIN orbit_markets om ON ol.market_id = om.id
+             JOIN petitions p ON ol.petition_id = p.id
+             ORDER BY ol.created_at DESC LIMIT 100`),
+      query(`SELECT COUNT(*)::int as count FROM orbit_feeds WHERE is_active = true`),
+      query(`SELECT COUNT(*)::int as count FROM orbit_articles`),
+      query(`SELECT id, title, content, category, tags, engagement_score, source_url, created_at
+             FROM trending_topics ORDER BY engagement_score DESC LIMIT 15`),
+    ]);
+
+    const stats = {
+      total_markets: marketsRes.rows.length,
+      synced_from_ken: upsertCount,
+      linked_petitions: linksRes.rows.length,
+      active_feeds: feedsRes.rows[0]?.count || 0,
+      recent_articles: articlesRes.rows[0]?.count || 0,
+    };
+
+    return NextResponse.json({
+      markets: marketsRes.rows,
+      petitions: petitionsRes.rows,
+      links: linksRes.rows,
+      trending: trendingRes.rows,
+      stats,
+    });
+  } catch (err) {
+    console.error('[api/orbit] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch orbit data' }, { status: 500 });
+  }
+}
+
+/**
+ * POST /api/orbit
+ * Create a market-petition link
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { market_id, petition_id, link_type, strength, ai_reasoning } = body;
+
+    if (!market_id || !petition_id) {
+      return NextResponse.json({ error: 'market_id and petition_id are required' }, { status: 400 });
+    }
+
+    const validLinkTypes = ['influence', 'counter', 'support'];
+    const type = validLinkTypes.includes(link_type) ? link_type : 'influence';
+    const str = typeof strength === 'number' ? Math.max(0, Math.min(1, strength)) : 0.5;
+
+    const result = await query(
+      `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+       VALUES ($1, $2, $3, $4, $5)
+       ON CONFLICT (market_id, petition_id) DO UPDATE SET
+         link_type = EXCLUDED.link_type,
+         strength = EXCLUDED.strength,
+         ai_reasoning = EXCLUDED.ai_reasoning
+       RETURNING *`,
+      [market_id, petition_id, type, str, ai_reasoning || null],
+    );
+
+    await auditLog('orbit.link_created', 'orbit_link', result.rows[0].id, {
+      market_id,
+      petition_id,
+      link_type: type,
+    });
+
+    return NextResponse.json({ link: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/orbit] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create orbit link' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/rss/route.ts b/app/api/orbit/rss/route.ts
new file mode 100644
index 0000000..12d4bd2
--- /dev/null
+++ b/app/api/orbit/rss/route.ts
@@ -0,0 +1,295 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+// ---- Simple RSS/XML parser using regex ----
+
+interface FeedItem {
+  title: string;
+  link: string;
+  description: string;
+  pubDate: string | null;
+}
+
+function extractTag(xml: string, tag: string): string {
+  // Handle both <tag>content</tag> and CDATA
+  const re = new RegExp(`<${tag}[^>]*>(?:<!\\[CDATA\\[)?(.*?)(?:\\]\\]>)?</${tag}>`, 's');
+  const m = xml.match(re);
+  return m ? m[1].trim() : '';
+}
+
+function parseRSS(xml: string): FeedItem[] {
+  const items: FeedItem[] = [];
+  // Match both <item> (RSS) and <entry> (Atom) elements
+  const itemRegex = /<(?:item|entry)[\s>]([\s\S]*?)<\/(?:item|entry)>/gi;
+  let match;
+  while ((match = itemRegex.exec(xml)) !== null) {
+    const block = match[1];
+    const title = extractTag(block, 'title');
+    // RSS uses <link>, Atom uses <link href="..."/>
+    let link = extractTag(block, 'link');
+    if (!link) {
+      const linkHref = block.match(/<link[^>]+href=["']([^"']+)["']/);
+      if (linkHref) link = linkHref[1];
+    }
+    const description = extractTag(block, 'description') || extractTag(block, 'summary') || extractTag(block, 'content');
+    const pubDate = extractTag(block, 'pubDate') || extractTag(block, 'published') || extractTag(block, 'updated') || null;
+
+    if (title && link) {
+      items.push({
+        title: title.replace(/<[^>]+>/g, '').trim(),
+        link: link.replace(/<[^>]+>/g, '').trim(),
+        description: description.replace(/<[^>]+>/g, '').substring(0, 500).trim(),
+        pubDate,
+      });
+    }
+  }
+  return items;
+}
+
+// ---- Gemini sentiment scoring ----
+
+async function scoreSentiment(
+  titles: string[],
+): Promise<Array<{ sentiment: string; score: number; relevance: number; tags: string[] }>> {
+  if (titles.length === 0) return [];
+
+  const prompt = `Analyze these news headlines for sentiment and political/advocacy relevance.
+For each headline, return:
+- sentiment: "positive", "negative", "neutral", or "mixed"
+- score: float from -1.0 (very negative) to 1.0 (very positive)
+- relevance: 0-100 score of how relevant this is to political advocacy, petitions, civic engagement
+- tags: 1-3 topic tags
+
+Headlines:
+${titles.map((t, i) => `${i + 1}. ${t}`).join('\n')}
+
+Return ONLY a JSON array with one object per headline, in the same order. Example:
+[{"sentiment":"negative","score":-0.6,"relevance":85,"tags":["healthcare","policy"]}]`;
+
+  // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+  const r = await callGemini<unknown>({ prompt, maxTokens: 4096, temperature: 0.3, timeoutMs: 30_000 });
+  if (!r.ok) {
+    console.error('[orbit/rss] gemini failed:', r.reason, r.detail);
+    return titles.map(() => ({ sentiment: 'neutral', score: 0, relevance: 50, tags: [] }));
+  }
+  if (Array.isArray(r.data)) return r.data;
+  return titles.map(() => ({ sentiment: 'neutral', score: 0, relevance: 50, tags: [] }));
+}
+
+// ---- Match articles to Kalshi markets by keyword overlap ----
+
+function matchToMarkets(articleTitle: string, markets: Array<{ ticker: string; title: string }>): string[] {
+  const matched: string[] = [];
+  const words = articleTitle.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
+
+  for (const m of markets) {
+    const marketWords = m.title.toLowerCase().split(/\W+/).filter((w) => w.length > 3);
+    const overlap = words.filter((w) => marketWords.includes(w));
+    if (overlap.length >= 2) {
+      matched.push(m.ticker);
+    }
+  }
+  return matched;
+}
+
+/**
+ * GET /api/orbit/rss
+ * List feeds + recent articles
+ */
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const url = new URL(request.url);
+    const category = url.searchParams.get('category');
+    const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200);
+
+    // Fetch feeds
+    let feedsSql = `SELECT * FROM orbit_feeds`;
+    const feedsParams: unknown[] = [];
+    if (category && ['national', 'local', 'wire'].includes(category)) {
+      feedsParams.push(category);
+      feedsSql += ` WHERE category = $1`;
+    }
+    feedsSql += ` ORDER BY category, name`;
+    const feedsRes = await query(feedsSql, feedsParams);
+
+    // Fetch articles
+    let articlesSql = `SELECT oa.*, of.name as feed_name, of.station, of.category as feed_category
+                       FROM orbit_articles oa
+                       JOIN orbit_feeds of ON oa.feed_id = of.id`;
+    const articlesParams: unknown[] = [];
+    if (category && ['national', 'local', 'wire'].includes(category)) {
+      articlesParams.push(category);
+      articlesSql += ` WHERE of.category = $1`;
+    }
+    articlesParams.push(limit);
+    articlesSql += ` ORDER BY oa.published_at DESC NULLS LAST, oa.created_at DESC LIMIT $${articlesParams.length}`;
+    const articlesRes = await query(articlesSql, articlesParams);
+
+    // Stats
+    const statsRes = await query(
+      `SELECT
+         (SELECT COUNT(*)::int FROM orbit_feeds) as total_feeds,
+         (SELECT COUNT(*)::int FROM orbit_articles) as total_articles,
+         (SELECT MAX(last_fetched_at) FROM orbit_feeds) as last_fetch`,
+    );
+
+    return NextResponse.json({
+      feeds: feedsRes.rows,
+      articles: articlesRes.rows,
+      stats: statsRes.rows[0],
+    });
+  } catch (err) {
+    console.error('[api/orbit/rss] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch RSS data' }, { status: 500 });
+  }
+}
+
+/**
+ * POST /api/orbit/rss
+ * Trigger manual RSS feed refresh
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const errors: string[] = [];
+  let totalFetched = 0;
+  let totalNew = 0;
+
+  try {
+    // Get all active feeds
+    const feedsRes = await query(`SELECT * FROM orbit_feeds WHERE is_active = true ORDER BY name`);
+    const feeds = feedsRes.rows;
+
+    // Get current markets for keyword matching
+    const marketsRes = await query(`SELECT ticker, title FROM orbit_markets`);
+    const markets = marketsRes.rows as Array<{ ticker: string; title: string }>;
+
+    // Process feeds in parallel batches of 5
+    const batchSize = 5;
+    for (let i = 0; i < feeds.length; i += batchSize) {
+      const batch = feeds.slice(i, i + batchSize);
+
+      const results = await Promise.allSettled(
+        batch.map(async (feed: any) => {
+          try {
+            const res = await fetch(feed.url, {
+              headers: {
+                'User-Agent': 'PattyPetitions/1.0 (RSS Reader)',
+                Accept: 'application/rss+xml, application/xml, text/xml, application/atom+xml',
+              },
+              signal: AbortSignal.timeout(15000),
+            });
+
+            if (!res.ok) {
+              throw new Error(`HTTP ${res.status} for ${feed.name}`);
+            }
+
+            const xml = await res.text();
+            const items = parseRSS(xml);
+            totalFetched += items.length;
+
+            // Score sentiment in batches of 20
+            const newArticles: FeedItem[] = [];
+            for (const item of items) {
+              // Check if already exists
+              const existing = await query(
+                `SELECT id FROM orbit_articles WHERE feed_id = $1 AND url = $2`,
+                [feed.id, item.link],
+              );
+              if (existing.rows.length === 0) {
+                newArticles.push(item);
+              }
+            }
+
+            if (newArticles.length === 0) {
+              // Update last_fetched_at even if no new articles
+              await query(`UPDATE orbit_feeds SET last_fetched_at = NOW() WHERE id = $1`, [feed.id]);
+              return { feed: feed.name, fetched: items.length, new: 0 };
+            }
+
+            // Score new articles with Gemini (batch of up to 20)
+            const titlesToScore = newArticles.slice(0, 20).map((a) => a.title);
+            const scores = await scoreSentiment(titlesToScore);
+
+            // Insert new articles
+            let inserted = 0;
+            for (let j = 0; j < newArticles.length; j++) {
+              const article = newArticles[j];
+              const score = scores[j] || { sentiment: 'neutral', score: 0, relevance: 50, tags: [] };
+              const matchedMarkets = matchToMarkets(article.title, markets);
+
+              try {
+                await query(
+                  `INSERT INTO orbit_articles (feed_id, title, url, summary, published_at,
+                    sentiment, sentiment_score, relevance_score, matched_markets, tags)
+                   VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+                   ON CONFLICT (feed_id, url) DO NOTHING`,
+                  [
+                    feed.id,
+                    article.title,
+                    article.link,
+                    article.description || null,
+                    article.pubDate ? new Date(article.pubDate).toISOString() : null,
+                    score.sentiment,
+                    score.score,
+                    score.relevance,
+                    matchedMarkets.length > 0 ? matchedMarkets : null,
+                    score.tags?.length > 0 ? score.tags : null,
+                  ],
+                );
+                inserted++;
+              } catch (insertErr) {
+                console.error('[orbit/rss] Insert error:', (insertErr as Error).message);
+              }
+            }
+
+            totalNew += inserted;
+
+            // Update feed stats
+            await query(
+              `UPDATE orbit_feeds SET last_fetched_at = NOW(), article_count = article_count + $2 WHERE id = $1`,
+              [feed.id, inserted],
+            );
+
+            return { feed: feed.name, fetched: items.length, new: inserted };
+          } catch (feedErr) {
+            const msg = `${feed.name}: ${(feedErr as Error).message}`;
+            errors.push(msg);
+            return { feed: feed.name, fetched: 0, new: 0, error: msg };
+          }
+        }),
+      );
+
+      // Collect results
+      for (const r of results) {
+        if (r.status === 'rejected') {
+          errors.push(r.reason?.message || 'Unknown feed error');
+        }
+      }
+    }
+
+    await auditLog('orbit.rss_refresh', 'orbit_feed', null, {
+      feeds_processed: feeds.length,
+      total_fetched: totalFetched,
+      total_new: totalNew,
+      errors_count: errors.length,
+    });
+
+    return NextResponse.json({
+      fetched: totalFetched,
+      new_articles: totalNew,
+      feeds_processed: feeds.length,
+      errors,
+    });
+  } catch (err) {
+    console.error('[api/orbit/rss] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to refresh RSS feeds' }, { status: 500 });
+  }
+}
diff --git a/app/api/orbit/suggest-alliance/route.ts b/app/api/orbit/suggest-alliance/route.ts
new file mode 100644
index 0000000..64a4c3f
--- /dev/null
+++ b/app/api/orbit/suggest-alliance/route.ts
@@ -0,0 +1,204 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { auditLog } from '@/lib/audit';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/orbit/suggest-alliance
+ * Generate an "unlikely alliance" petition from 2 Kalshi markets in different categories.
+ * The AI finds common ground between seemingly opposed groups for maximum political impact.
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { market_tickers, tone } = body;
+
+    if (!market_tickers || !Array.isArray(market_tickers) || market_tickers.length !== 2) {
+      return NextResponse.json({ error: 'market_tickers must be an array of exactly 2 tickers' }, { status: 400 });
+    }
+
+    // Fetch both markets
+    const [res1, res2] = await Promise.all([
+      query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_tickers[0]]),
+      query(`SELECT * FROM orbit_markets WHERE ticker = $1`, [market_tickers[1]]),
+    ]);
+
+    if (res1.rows.length === 0 || res2.rows.length === 0) {
+      return NextResponse.json({ error: 'One or both markets not found. Sync markets first.' }, { status: 404 });
+    }
+
+    const marketA = res1.rows[0];
+    const marketB = res2.rows[0];
+
+    if (marketA.category === marketB.category) {
+      return NextResponse.json({ error: 'Alliance petitions require markets from different categories.' }, { status: 400 });
+    }
+
+    const toneDesc = tone || 'urgent';
+
+    const prompt = `You are a master political strategist and coalition builder. Your specialty is finding UNLIKELY ALLIANCES between groups that normally oppose each other. When these groups unite on a specific issue, the political impact is 10x greater than either group alone — this is the "Baptists and bootleggers" principle.
+
+MARKET A (${marketA.category}):
+- Ticker: ${marketA.ticker}
+- Title: ${marketA.title}
+- Subtitle: ${marketA.subtitle || 'N/A'}
+- Category: ${marketA.category}
+- Probability: ${marketA.last_price || marketA.yes_bid || 'N/A'}%
+- Volume: ${marketA.volume || 'N/A'} contracts
+
+MARKET B (${marketB.category}):
+- Ticker: ${marketB.ticker}
+- Title: ${marketB.title}
+- Subtitle: ${marketB.subtitle || 'N/A'}
+- Category: ${marketB.category}
+- Probability: ${marketB.last_price || marketB.yes_bid || 'N/A'}%
+- Volume: ${marketB.volume || 'N/A'} contracts
+
+YOUR TASK:
+Find the INTERSECTION where supporters of both issues can agree on ONE petition.
+The petition should feel authentic to BOTH audiences. Frame it around shared values
+(freedom, fairness, safety, economic opportunity) rather than partisan language.
+
+Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented' : toneDesc === 'formal' ? 'professional, data-driven' : toneDesc === 'passionate' ? 'emotional, empathetic' : 'practical, solution-focused'})
+
+RULES:
+- Do NOT mention Kalshi, prediction markets, or betting
+- The petition must appeal genuinely to BOTH groups
+- Find a policy angle where both groups' interests align
+- Use specific, actionable demands
+
+Return a JSON object with EXACTLY these fields:
+{
+  "title": "Alliance petition title (max 100 chars)",
+  "summary": "2-3 sentence hook explaining why these groups should unite (max 300 chars)",
+  "body_html": "<p>Full petition body in HTML. 5-7 paragraphs. Address both constituencies directly. Include specific demands, shared values, urgency, and a unified call to action. Use <strong>, <em>, <ul>, <li> tags.</p>",
+  "body_text": "Plain text version of the body",
+  "suggested_target": "The specific person, office, or body to address this petition to",
+  "suggested_tags": ["array", "of", "4-6", "relevant", "tags", "including-both-categories"],
+  "suggested_goal": 25000,
+  "alliance_insight": "2-3 sentences explaining why this alliance between ${marketA.category} and ${marketB.category} is politically powerful and surprising.",
+  "group_a_appeal": "2-3 sentences explaining specifically why ${marketA.category} supporters would sign this petition.",
+  "group_b_appeal": "2-3 sentences explaining specifically why ${marketB.category} supporters would sign this petition.",
+  "impact_multiplier": "2-3 sentences explaining why this joint petition achieves more than two separate petitions. Be specific about the political dynamics."
+}`;
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<Record<string, unknown>>({ prompt, maxTokens: 4096, temperature: 0.85, timeoutMs: 30_000 });
+    if (!r.ok) {
+      console.error('[orbit/alliance] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+        { status: r.status },
+      );
+    }
+    const parsed = r.data as Record<string, unknown>;
+
+    // Create petition
+    const title = String(parsed.title || `Alliance: ${marketA.category} + ${marketB.category}`);
+    let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+
+    const existingSlug = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+    if (existingSlug.rows.length > 0) {
+      slug = `${slug}-${Date.now().toString(36)}`;
+    }
+
+    const userResult = await query(`SELECT id FROM users LIMIT 1`);
+    const userId = userResult.rows[0]?.id;
+    if (!userId) {
+      return NextResponse.json({ error: 'No user found in system' }, { status: 500 });
+    }
+
+    const petitionResult = await query(
+      `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, category, tags,
+        signature_goal, ai_source, ai_topic_data, status)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+       RETURNING *`,
+      [
+        userId,
+        title,
+        slug,
+        parsed.summary || null,
+        parsed.body_html || '<p>Alliance petition content</p>',
+        parsed.body_text || null,
+        parsed.suggested_target || null,
+        'alliance',
+        parsed.suggested_tags || null,
+        parsed.suggested_goal || 25000,
+        'kalshi_alliance',
+        JSON.stringify({
+          market_a: { ticker: marketA.ticker, title: marketA.title, category: marketA.category },
+          market_b: { ticker: marketB.ticker, title: marketB.title, category: marketB.category },
+          alliance_insight: parsed.alliance_insight,
+          group_a_appeal: parsed.group_a_appeal,
+          group_b_appeal: parsed.group_b_appeal,
+          impact_multiplier: parsed.impact_multiplier,
+          generated_at: new Date().toISOString(),
+        }),
+        'draft',
+      ],
+    );
+    const petition = petitionResult.rows[0];
+
+    // Create TWO orbit_links — one per market
+    const [linkA, linkB] = await Promise.all([
+      query(
+        `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+         VALUES ($1, $2, 'alliance', 0.9, $3)
+         ON CONFLICT (market_id, petition_id) DO UPDATE SET
+           link_type = 'alliance', ai_reasoning = EXCLUDED.ai_reasoning, strength = 0.9
+         RETURNING *`,
+        [marketA.id, petition.id, parsed.group_a_appeal || 'Alliance petition member.']
+      ),
+      query(
+        `INSERT INTO orbit_links (market_id, petition_id, link_type, strength, ai_reasoning)
+         VALUES ($1, $2, 'alliance', 0.9, $3)
+         ON CONFLICT (market_id, petition_id) DO UPDATE SET
+           link_type = 'alliance', ai_reasoning = EXCLUDED.ai_reasoning, strength = 0.9
+         RETURNING *`,
+        [marketB.id, petition.id, parsed.group_b_appeal || 'Alliance petition member.']
+      ),
+    ]);
+
+    // Update both markets' petition_relevance
+    await Promise.all([
+      query(
+        `UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, 85), suggested_petition = $2 WHERE id = $1`,
+        [marketA.id, title]
+      ),
+      query(
+        `UPDATE orbit_markets SET petition_relevance = GREATEST(petition_relevance, 85), suggested_petition = $2 WHERE id = $1`,
+        [marketB.id, title]
+      ),
+    ]);
+
+    await auditLog('orbit.alliance_generated', 'petition', petition.id, {
+      market_a: marketA.ticker,
+      market_b: marketB.ticker,
+      category_a: marketA.category,
+      category_b: marketB.category,
+      tone: toneDesc,
+    });
+
+    return NextResponse.json({
+      petition: {
+        ...petition,
+        alliance_insight: parsed.alliance_insight || '',
+        group_a_appeal: parsed.group_a_appeal || '',
+        group_b_appeal: parsed.group_b_appeal || '',
+        impact_multiplier: parsed.impact_multiplier || '',
+      },
+      links: [linkA.rows[0], linkB.rows[0]],
+      markets: {
+        a: { ticker: marketA.ticker, title: marketA.title, category: marketA.category },
+        b: { ticker: marketB.ticker, title: marketB.title, category: marketB.category },
+      },
+    });
+  } catch (err) {
+    console.error('[api/orbit/suggest-alliance] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to generate alliance petition' }, { status: 500 });
+  }
+}
diff --git a/app/api/petitions/[slug]/post-log/route.ts b/app/api/petitions/[slug]/post-log/route.ts
new file mode 100644
index 0000000..505763e
--- /dev/null
+++ b/app/api/petitions/[slug]/post-log/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { query } from '@/lib/db';
+
+/**
+ * GET /api/petitions/[slug]/post-log
+ * Returns posting history for a petition.
+ */
+export async function GET(
+  request: NextRequest,
+  { params }: { params: Promise<{ slug: string }> },
+) {
+  const user = verifyAuth(request);
+  if (!user) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  }
+
+  const { slug } = await params;
+
+  // Look up petition by slug
+  const petitionResult = await query(
+    `SELECT id FROM petitions WHERE slug = $1 LIMIT 1`,
+    [slug],
+  );
+
+  if (petitionResult.rows.length === 0) {
+    return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+  }
+
+  const petitionId = petitionResult.rows[0].id;
+
+  const logsResult = await query(
+    `SELECT id, platform, post_text, post_url, external_id, status, error_msg,
+            platform_config, actor, scheduled_at, posted_at, created_at
+     FROM petition_posts
+     WHERE petition_id = $1
+     ORDER BY created_at DESC
+     LIMIT 50`,
+    [petitionId],
+  );
+
+  return NextResponse.json({
+    petition_id: petitionId,
+    slug,
+    logs: logsResult.rows,
+    total: logsResult.rowCount,
+  });
+}
diff --git a/app/api/petitions/[slug]/post/route.ts b/app/api/petitions/[slug]/post/route.ts
new file mode 100644
index 0000000..2c92b0b
--- /dev/null
+++ b/app/api/petitions/[slug]/post/route.ts
@@ -0,0 +1,251 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { query } from '@/lib/db';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const PULSE_BASE = 'http://127.0.0.1:9845';
+
+interface PostPlatform {
+  name: string;
+  enabled: boolean;
+  config?: Record<string, string>;
+}
+
+/**
+ * POST /api/petitions/[slug]/post
+ * Dispatches a petition to selected platforms via the Pulse agent.
+ * Logs each dispatch decision to the petition_posts table.
+ */
+export async function POST(
+  request: NextRequest,
+  { params }: { params: Promise<{ slug: string }> },
+) {
+  const user = verifyAuth(request);
+  if (!user) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  }
+  if (!SISTER_OK) {
+    return sisterUnconfigured();
+  }
+
+  const { slug } = await params;
+
+  // Look up the petition
+  const petitionResult = await query(
+    `SELECT id, title, summary, body_text, slug, status FROM petitions WHERE slug = $1 LIMIT 1`,
+    [slug],
+  );
+
+  if (petitionResult.rows.length === 0) {
+    return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+  }
+
+  const petition = petitionResult.rows[0];
+
+  let body: { platforms: PostPlatform[]; action?: string };
+  try {
+    body = await request.json();
+  } catch {
+    return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
+  }
+
+  const { platforms, action = 'post' } = body;
+
+  if (!Array.isArray(platforms) || platforms.length === 0) {
+    return NextResponse.json({ error: 'platforms[] required' }, { status: 400 });
+  }
+
+  const enabledPlatforms = platforms.filter((p) => p.enabled);
+  if (enabledPlatforms.length === 0) {
+    return NextResponse.json({ error: 'No platforms enabled' }, { status: 400 });
+  }
+
+  const petitionUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+  const results: Array<{
+    platform: string;
+    status: string;
+    message: string;
+    post_id?: string;
+  }> = [];
+
+  // Build dispatch payload for the Pulse agent
+  const targetAgents: string[] = [];
+  const platformParams: Record<string, string> = {};
+
+  for (const p of enabledPlatforms) {
+    const name = p.name.toLowerCase();
+
+    // Patty "launch" is just marking the petition as featured/active -- handled locally
+    if (name === 'patty') {
+      try {
+        await query(
+          `UPDATE petitions SET status = 'active', distribution_platform = 'patty', updated_at = NOW() WHERE id = $1`,
+          [petition.id],
+        );
+
+        const insertResult = await query(
+          `INSERT INTO petition_posts (petition_id, platform, post_text, post_url, status, posted_at, platform_config, actor)
+           VALUES ($1, 'patty', $2, $3, 'posted', NOW(), $4, $5) RETURNING id`,
+          [
+            petition.id,
+            petition.summary || petition.title,
+            petitionUrl,
+            JSON.stringify(p.config || {}),
+            user,
+          ],
+        );
+
+        results.push({
+          platform: 'patty',
+          status: 'posted',
+          message: 'Launched on Patty platform',
+          post_id: insertResult.rows[0]?.id,
+        });
+      } catch (err: unknown) {
+        const msg = err instanceof Error ? err.message : 'Unknown error';
+        results.push({ platform: 'patty', status: 'failed', message: msg });
+      }
+      continue;
+    }
+
+    // For social platforms, we dispatch through Pulse
+    const socialPlatforms = ['reddit', 'discord', 'twitter', 'bluesky', 'moveon', 'instagram', 'tiktok'];
+    if (!socialPlatforms.includes(name)) {
+      results.push({ platform: name, status: 'skipped', message: `Unknown platform: ${name}` });
+      continue;
+    }
+
+    // Collect platform-specific config
+    if (name === 'reddit' && p.config?.subreddit) {
+      platformParams.reddit_subreddit = p.config.subreddit;
+    }
+    if (name === 'discord' && p.config?.channel_id) {
+      platformParams.discord_channel_id = p.config.channel_id;
+    }
+
+    targetAgents.push(name);
+
+    // Log as pending -- we update after dispatch
+    try {
+      const insertResult = await query(
+        `INSERT INTO petition_posts (petition_id, platform, post_text, post_url, status, platform_config, actor)
+         VALUES ($1, $2, $3, $4, 'pending', $5, $6) RETURNING id`,
+        [
+          petition.id,
+          name,
+          petition.summary || petition.title,
+          petitionUrl,
+          JSON.stringify(p.config || {}),
+          user,
+        ],
+      );
+
+      const postId = insertResult.rows[0]?.id;
+
+      results.push({
+        platform: name,
+        status: 'pending',
+        message: 'Queued for dispatch',
+        post_id: postId,
+      });
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : 'Unknown error';
+      results.push({ platform: name, status: 'failed', message: msg });
+    }
+  }
+
+  // If we have social platform targets, dispatch through Pulse agent
+  if (targetAgents.length > 0) {
+    try {
+      const dispatchPayload = {
+        petition_id: petition.id,
+        title: petition.title,
+        body: petition.body_text || petition.summary || petition.title,
+        petition_url: petitionUrl,
+        target_agents: targetAgents,
+        platform_params: platformParams,
+      };
+
+      const pulseRes = await fetch(`${PULSE_BASE}/api/pulse/dispatch`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: SISTER_AUTH,
+        },
+        body: JSON.stringify(dispatchPayload),
+        signal: AbortSignal.timeout(15000),
+      });
+
+      const pulseData = await pulseRes.json();
+
+      // Update each post record with dispatch results
+      if (pulseData.results && Array.isArray(pulseData.results)) {
+        for (const pr of pulseData.results) {
+          const matchResult = results.find(
+            (r) => r.platform === pr.agent && r.post_id,
+          );
+          if (matchResult && matchResult.post_id) {
+            const newStatus = pr.status === 'dispatched' ? 'posted' : 'failed';
+            const errorMsg =
+              pr.status !== 'dispatched' ? pr.message || 'Dispatch failed' : null;
+
+            await query(
+              `UPDATE petition_posts SET status = $1, error_msg = $2, posted_at = CASE WHEN $1 = 'posted' THEN NOW() ELSE posted_at END WHERE id = $3`,
+              [newStatus, errorMsg, matchResult.post_id],
+            );
+
+            matchResult.status = newStatus;
+            matchResult.message =
+              pr.status === 'dispatched'
+                ? 'Posted via Pulse'
+                : pr.message || 'Dispatch failed';
+          }
+        }
+      }
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : 'Pulse unavailable';
+      // Mark all pending as failed
+      for (const r of results) {
+        if (r.status === 'pending' && r.post_id) {
+          await query(
+            `UPDATE petition_posts SET status = 'failed', error_msg = $1 WHERE id = $2`,
+            [msg, r.post_id],
+          );
+          r.status = 'failed';
+          r.message = msg;
+        }
+      }
+    }
+  }
+
+  // Log audit event
+  await query(
+    `INSERT INTO audit_events (event_type, entity_type, entity_id, actor, metadata)
+     VALUES ('petition_post', 'petition', $1, $2, $3)`,
+    [
+      petition.id,
+      user,
+      JSON.stringify({
+        platforms: enabledPlatforms.map((p) => p.name),
+        action,
+        results: results.map((r) => ({
+          platform: r.platform,
+          status: r.status,
+        })),
+      }),
+    ],
+  );
+
+  return NextResponse.json({
+    petition_id: petition.id,
+    slug,
+    dispatched_at: new Date().toISOString(),
+    results,
+    summary: {
+      total: enabledPlatforms.length,
+      posted: results.filter((r) => r.status === 'posted').length,
+      failed: results.filter((r) => r.status === 'failed').length,
+      skipped: results.filter((r) => r.status === 'skipped').length,
+    },
+  });
+}
diff --git a/app/api/petitions/generate/route.ts b/app/api/petitions/generate/route.ts
new file mode 100644
index 0000000..8ed1ef8
--- /dev/null
+++ b/app/api/petitions/generate/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+type PetitionShape = {
+  title?: string;
+  summary?: string;
+  body_html?: string;
+  body_text?: string;
+  suggested_target?: string;
+  suggested_tags?: string[];
+  suggested_goal?: number;
+};
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { topic, target, tone, category } = body;
+
+    if (!topic) {
+      return NextResponse.json({ error: 'Topic is required' }, { status: 400 });
+    }
+
+    const toneDesc = tone || 'formal';
+    const targetDesc = target || 'Congress and the Department of Education';
+    const categoryDesc = category || 'policy';
+
+    const prompt = `You are a professional petition writer specializing in student debt and education advocacy. Generate a compelling petition about the following topic.
+
+Topic: ${topic}
+Target: ${targetDesc}
+Tone: ${toneDesc} (${toneDesc === 'urgent' ? 'time-sensitive, action-oriented language' : toneDesc === 'formal' ? 'professional, respectful, data-driven' : toneDesc === 'passionate' ? 'emotional, personal stories, empathetic' : 'community-focused, grassroots, collective action'})
+Category: ${categoryDesc}
+
+Return a JSON object with EXACTLY these fields:
+{
+  "title": "A compelling petition title (max 100 chars)",
+  "summary": "A 2-3 sentence summary that hooks readers (max 300 chars)",
+  "body_html": "<p>Full petition body in HTML format with multiple paragraphs. Include specific demands, statistics where appropriate, and a clear call to action. Should be 3-5 paragraphs long. Use <strong>, <em>, <ul>, <li> tags for emphasis and lists.</p>",
+  "body_text": "Plain text version of the petition body",
+  "suggested_target": "The specific person, office, or body to address",
+  "suggested_tags": ["array", "of", "relevant", "tags"],
+  "suggested_goal": 500
+}
+
+Make the petition specific, actionable, and compelling. Focus on student debt, education policy, and higher education issues. The suggested_goal should be between 100 and 10000 based on the scope of the petition.`;
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<PetitionShape>({ prompt, maxTokens: 2048, temperature: 0.8 });
+    if (!r.ok) {
+      console.error('[petitions/generate] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI generation failed' },
+        { status: r.status },
+      );
+    }
+    const parsed = r.data;
+
+    return NextResponse.json({
+      title: parsed.title || '',
+      summary: parsed.summary || '',
+      body_html: parsed.body_html || '',
+      body_text: parsed.body_text || '',
+      suggested_target: parsed.suggested_target || targetDesc,
+      suggested_tags: parsed.suggested_tags || [],
+      suggested_goal: parsed.suggested_goal || 500,
+    });
+  } catch (err) {
+    console.error('[api/petitions/generate] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to generate petition' }, { status: 500 });
+  }
+}
diff --git a/app/api/petitions/route.ts b/app/api/petitions/route.ts
new file mode 100644
index 0000000..fbf6166
--- /dev/null
+++ b/app/api/petitions/route.ts
@@ -0,0 +1,232 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { sanitizePetitionBody, bodyHtmlTooLarge } from '@/lib/sanitize';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const status = url.searchParams.get('status');
+  const search = url.searchParams.get('search');
+
+  let sql = `SELECT * FROM petitions`;
+  const params: unknown[] = [];
+  const conditions: string[] = [];
+
+  if (status && status !== 'all') {
+    params.push(status);
+    conditions.push(`status = $${params.length}`);
+  }
+
+  if (search) {
+    params.push(`%${search}%`);
+    conditions.push(`(title ILIKE $${params.length} OR target ILIKE $${params.length})`);
+  }
+
+  if (conditions.length > 0) {
+    sql += ` WHERE ` + conditions.join(' AND ');
+  }
+
+  sql += ` ORDER BY created_at DESC`;
+
+  try {
+    const result = await query(sql, params);
+
+    // Get status counts
+    const countsResult = await query(
+      `SELECT status, COUNT(*)::int as count FROM petitions GROUP BY status`
+    );
+    const statusCounts: Record<string, number> = { draft: 0, active: 0, paused: 0, closed: 0, delivered: 0 };
+    for (const row of countsResult.rows) {
+      statusCounts[row.status] = row.count;
+    }
+    statusCounts.all = Object.values(statusCounts).reduce((a, b) => a + b, 0);
+
+    return NextResponse.json({ rows: result.rows, statusCounts });
+  } catch (err) {
+    console.error('[api/petitions] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch petitions' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { title, summary, body_html, body_text, target, target_emails, category, tags: rawTags, signature_goal, org_type, status } = body;
+
+    // Normalize tags: accept string ("a, b, c"), array, or null
+    let tags: string[] | null = null;
+    if (Array.isArray(rawTags)) {
+      tags = rawTags;
+    } else if (typeof rawTags === 'string' && rawTags.trim()) {
+      tags = rawTags.split(',').map((t: string) => t.trim()).filter(Boolean);
+    }
+
+    if (!title || !body_html) {
+      return NextResponse.json({ error: 'Title and body_html are required' }, { status: 400 });
+    }
+
+    // 2026-05-05 (P1 architect-review): boundary sanitize at storage. Render
+    // ALSO sanitizes (defense-in-depth) but the database is the trust boundary.
+    if (bodyHtmlTooLarge(body_html)) {
+      return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
+    }
+    const cleanBodyHtml = sanitizePetitionBody(body_html);
+
+    // Generate slug from title
+    let slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+    // Check for duplicate slug
+    const existing = await query(`SELECT id FROM petitions WHERE slug = $1`, [slug]);
+    if (existing.rows.length > 0) {
+      slug = `${slug}-${Date.now().toString(36)}`;
+    }
+
+    // Get user_id from DB
+    const userResult = await query(`SELECT id FROM users LIMIT 1`);
+    const userId = userResult.rows[0]?.id;
+    if (!userId) {
+      return NextResponse.json({ error: 'No user found' }, { status: 500 });
+    }
+
+    const result = await query(
+      `INSERT INTO petitions (user_id, title, slug, summary, body_html, body_text, target, target_emails, category, tags, signature_goal, org_type, status)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+       RETURNING *`,
+      [
+        userId,
+        title,
+        slug,
+        summary || null,
+        cleanBodyHtml,
+        body_text || null,
+        target || null,
+        target_emails || null,
+        category || null,
+        tags || null,
+        signature_goal || 100,
+        org_type || null,
+        status || 'draft',
+      ]
+    );
+
+    return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/petitions] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create petition' }, { status: 500 });
+  }
+}
+
+export async function PATCH(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { id, ...updates } = body;
+
+    if (!id) {
+      return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
+    }
+
+    const allowedFields = ['title', 'summary', 'body_html', 'body_text', 'target', 'target_emails', 'category', 'tags', 'signature_goal', 'org_type', 'status', 'image_url', 'compliance_flags'];
+    const setClauses: string[] = [];
+    const params: unknown[] = [];
+
+    for (const [key, value] of Object.entries(updates)) {
+      if (allowedFields.includes(key)) {
+        let normalizedValue = value;
+        // Normalize tags: text[] column needs a real array, not a comma string
+        if (key === 'tags') {
+          if (Array.isArray(value)) {
+            normalizedValue = value.map((t: string) => String(t).trim()).filter(Boolean);
+          } else if (typeof value === 'string' && value.trim()) {
+            normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
+          } else {
+            normalizedValue = null;
+          }
+        }
+        // Normalize target_emails similarly
+        if (key === 'target_emails') {
+          if (Array.isArray(value)) {
+            normalizedValue = value;
+          } else if (typeof value === 'string' && value.trim()) {
+            normalizedValue = value.split(',').map((t: string) => t.trim()).filter(Boolean);
+          } else {
+            normalizedValue = null;
+          }
+        }
+        // 2026-05-05 (P1): boundary sanitize body_html on PATCH too.
+        if (key === 'body_html') {
+          if (bodyHtmlTooLarge(value)) {
+            return NextResponse.json({ error: 'body_html too large (max 200KB)' }, { status: 400 });
+          }
+          normalizedValue = sanitizePetitionBody(value as string);
+        }
+        params.push(normalizedValue);
+        setClauses.push(`${key} = $${params.length}`);
+      }
+    }
+
+    if (setClauses.length === 0) {
+      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+    }
+
+    // When status changes to "active", auto-generate public URL
+    if (updates.status === 'active') {
+      // Get the petition's slug first
+      const petitionResult = await query(`SELECT slug FROM petitions WHERE id = $1`, [id]);
+      if (petitionResult.rows.length > 0) {
+        const slug = petitionResult.rows[0].slug;
+        const publicUrl = `http://45.61.58.125:7460/petitions/${slug}`;
+        params.push(publicUrl);
+        setClauses.push(`external_url = $${params.length}`);
+      }
+    }
+
+    params.push(id);
+    const sql = `UPDATE petitions SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+    const result = await query(sql, params);
+    if (result.rows.length === 0) {
+      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ row: result.rows[0] });
+  } catch (err) {
+    console.error('[api/petitions] PATCH error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to update petition' }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const url = new URL(request.url);
+    const id = url.searchParams.get('id');
+
+    if (!id) {
+      return NextResponse.json({ error: 'Petition id is required' }, { status: 400 });
+    }
+
+    const result = await query(
+      `UPDATE petitions SET status = 'closed' WHERE id = $1 RETURNING *`,
+      [id]
+    );
+
+    if (result.rows.length === 0) {
+      return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ row: result.rows[0] });
+  } catch (err) {
+    console.error('[api/petitions] DELETE error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to close petition' }, { status: 500 });
+  }
+}
diff --git a/app/api/public/signatures/route.ts b/app/api/public/signatures/route.ts
new file mode 100644
index 0000000..fc54356
--- /dev/null
+++ b/app/api/public/signatures/route.ts
@@ -0,0 +1,209 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query, getClient } from '@/lib/db';
+
+/**
+ * Simple in-memory rate limiter: max 10 signatures per IP per hour.
+ * Resets on server restart — acceptable for this use case.
+ */
+const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
+const RATE_LIMIT_MAX = 10;
+const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+
+function checkRateLimit(ip: string): boolean {
+  const now = Date.now();
+  const entry = rateLimitMap.get(ip);
+
+  if (!entry || now > entry.resetAt) {
+    rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
+    return true;
+  }
+
+  if (entry.count >= RATE_LIMIT_MAX) {
+    return false;
+  }
+
+  entry.count++;
+  return true;
+}
+
+// Clean up expired entries every 10 minutes to prevent memory leaks
+setInterval(() => {
+  const now = Date.now();
+  for (const [key, value] of rateLimitMap.entries()) {
+    if (now > value.resetAt) {
+      rateLimitMap.delete(key);
+    }
+  }
+}, 10 * 60 * 1000);
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json();
+    const {
+      petition_id,
+      signer_name,
+      signer_email,
+      signer_zip,
+      signer_comment,
+      opted_in_email,
+      _hp, // honeypot
+    } = body;
+
+    // Honeypot check: if this hidden field has a value, it's a bot
+    if (_hp) {
+      // Return success to not reveal the check to bots
+      return NextResponse.json({ success: true, signatureCount: 0 }, { status: 200 });
+    }
+
+    // Get IP address
+    const forwarded = request.headers.get('x-forwarded-for');
+    const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
+
+    // Rate limit check
+    if (!checkRateLimit(ip)) {
+      return NextResponse.json(
+        { error: 'Too many signatures from this IP address. Please try again later.' },
+        { status: 429 }
+      );
+    }
+
+    // Validation: required fields
+    if (!petition_id) {
+      return NextResponse.json({ error: 'Petition ID is required.' }, { status: 400 });
+    }
+
+    if (!signer_name || typeof signer_name !== 'string' || signer_name.trim().length === 0) {
+      return NextResponse.json({ error: 'Please enter your name.' }, { status: 400 });
+    }
+
+    if (signer_name.trim().length > 200) {
+      return NextResponse.json({ error: 'Name is too long.' }, { status: 400 });
+    }
+
+    if (!signer_email || typeof signer_email !== 'string') {
+      return NextResponse.json({ error: 'Please enter your email address.' }, { status: 400 });
+    }
+
+    // Email format validation
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    if (!emailRegex.test(signer_email.trim())) {
+      return NextResponse.json({ error: 'Please enter a valid email address.' }, { status: 400 });
+    }
+
+    if (signer_email.trim().length > 320) {
+      return NextResponse.json({ error: 'Email address is too long.' }, { status: 400 });
+    }
+
+    // Validate optional fields
+    if (signer_comment && typeof signer_comment === 'string' && signer_comment.length > 1000) {
+      return NextResponse.json({ error: 'Comment is too long (max 1000 characters).' }, { status: 400 });
+    }
+
+    if (signer_zip && typeof signer_zip === 'string' && signer_zip.length > 10) {
+      return NextResponse.json({ error: 'ZIP code is too long.' }, { status: 400 });
+    }
+
+    // Check petition exists and is active
+    const petitionResult = await query(
+      `SELECT id, status, signature_count FROM petitions WHERE id = $1`,
+      [petition_id]
+    );
+
+    if (petitionResult.rows.length === 0) {
+      return NextResponse.json({ error: 'Petition not found.' }, { status: 404 });
+    }
+
+    const petition = petitionResult.rows[0];
+    if (petition.status !== 'active') {
+      return NextResponse.json(
+        { error: 'This petition is not currently accepting signatures.' },
+        { status: 403 }
+      );
+    }
+
+    // Use a transaction for atomicity
+    const client = await getClient();
+
+    try {
+      await client.query('BEGIN');
+
+      // Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
+      const sigResult = await client.query(
+        `INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email, source)
+         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'public')
+         ON CONFLICT (petition_id, signer_email) DO NOTHING
+         RETURNING *`,
+        [
+          petition_id,
+          signer_email.trim().toLowerCase(),
+          signer_name.trim(),
+          signer_zip?.trim() || null,
+          signer_comment?.trim() || null,
+          true, // public signatures by default
+          ip,
+          opted_in_email === true,
+        ]
+      );
+
+      if (sigResult.rows.length === 0) {
+        // Duplicate — email already signed this petition
+        await client.query('ROLLBACK');
+
+        // Return the current count so the UI can still show it
+        const countResult = await query(
+          `SELECT signature_count FROM petitions WHERE id = $1`,
+          [petition_id]
+        );
+
+        return NextResponse.json(
+          {
+            error: 'You have already signed this petition. Thank you for your support!',
+            signatureCount: countResult.rows[0]?.signature_count || 0,
+          },
+          { status: 409 }
+        );
+      }
+
+      // Update petition signature_count
+      const updateResult = await client.query(
+        `UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1 RETURNING signature_count`,
+        [petition_id]
+      );
+
+      const newCount = updateResult.rows[0]?.signature_count || 0;
+
+      // If opted in, add to email_subscribers
+      if (opted_in_email === true) {
+        await client.query(
+          `INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
+           VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
+           ON CONFLICT DO NOTHING`,
+          [
+            signer_email.trim().toLowerCase(),
+            signer_name.trim(),
+            signer_zip?.trim() || null,
+            petition_id,
+          ]
+        );
+      }
+
+      await client.query('COMMIT');
+
+      return NextResponse.json(
+        { success: true, signatureCount: newCount },
+        { status: 201 }
+      );
+    } catch (err) {
+      await client.query('ROLLBACK');
+      throw err;
+    } finally {
+      client.release();
+    }
+  } catch (err) {
+    console.error('[api/public/signatures] POST error:', (err as Error).message);
+    return NextResponse.json(
+      { error: 'Something went wrong. Please try again.' },
+      { status: 500 }
+    );
+  }
+}
diff --git a/app/api/pulse/[...path]/route.ts b/app/api/pulse/[...path]/route.ts
new file mode 100644
index 0000000..1345fa5
--- /dev/null
+++ b/app/api/pulse/[...path]/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+import { SISTER_AUTH, SISTER_OK, sisterUnconfigured } from '@/lib/sister-auth';
+
+const PULSE_BASE = 'http://127.0.0.1:9845';
+
+async function proxyToPulse(request: NextRequest, params: Promise<{ path: string[] }>, method: string) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  if (!SISTER_OK) return sisterUnconfigured();
+
+  const { path } = await params;
+  const pathStr = path.join('/');
+  const url = new URL(request.url);
+  const qs = url.search;
+
+  const headers: Record<string, string> = { Authorization: SISTER_AUTH };
+  let body: string | undefined;
+
+  if (method === 'POST' || method === 'PUT') {
+    headers['Content-Type'] = 'application/json';
+    body = await request.text();
+  }
+
+  try {
+    const res = await fetch(`${PULSE_BASE}/api/pulse/${pathStr}${qs}`, {
+      method,
+      headers,
+      body,
+      signal: AbortSignal.timeout(15000),
+    });
+    const data = await res.json();
+    return NextResponse.json(data, { status: res.status });
+  } catch (err) {
+    return NextResponse.json(
+      { error: 'Pulse service unavailable' },
+      { status: 502 },
+    );
+  }
+}
+
+export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToPulse(req, params, 'GET');
+}
+
+export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToPulse(req, params, 'POST');
+}
+
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToPulse(req, params, 'DELETE');
+}
+
+export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+  return proxyToPulse(req, params, 'PUT');
+}
diff --git a/app/api/settings/credentials/route.ts b/app/api/settings/credentials/route.ts
new file mode 100644
index 0000000..334728e
--- /dev/null
+++ b/app/api/settings/credentials/route.ts
@@ -0,0 +1,146 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs';
+import path from 'path';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+const execAsync = promisify(exec);
+
+const AGENTS_BASE = '/root/Projects/SDCC/agents';
+
+const AGENT_ENV_MAP: Record<string, { dir: string; pm2Name: string; vars: string[] }> = {
+  twitter: {
+    dir: path.join(AGENTS_BASE, 'twitter-agent'),
+    pm2Name: 'sdcc-twitter',
+    vars: ['TWITTER_API_KEY', 'TWITTER_API_SECRET', 'TWITTER_ACCESS_TOKEN', 'TWITTER_ACCESS_SECRET', 'TWITTER_BEARER_TOKEN'],
+  },
+  reddit: {
+    dir: path.join(AGENTS_BASE, 'reddit-agent'),
+    pm2Name: 'sdcc-reddit',
+    vars: ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD'],
+  },
+  discord: {
+    dir: path.join(AGENTS_BASE, 'discord-agent'),
+    pm2Name: 'sdcc-discord',
+    vars: ['DISCORD_BOT_TOKEN', 'DISCORD_DEFAULT_CHANNEL_ID'],
+  },
+  bluesky: {
+    dir: path.join(AGENTS_BASE, 'bluesky-agent'),
+    pm2Name: 'sdcc-bluesky',
+    vars: ['BSKY_HANDLE', 'BSKY_PASSWORD'],
+  },
+};
+
+function maskValue(val: string): string {
+  if (!val || val.length < 4) return '***';
+  return val.slice(0, 3) + '*'.repeat(Math.min(val.length - 3, 12));
+}
+
+function buildEnvFile(vars: Record<string, string>): string {
+  return Object.entries(vars)
+    .filter(([, v]) => v)
+    .map(([k, v]) => `${k}=${v}`)
+    .join('\n') + '\n';
+}
+
+/**
+ * GET /api/settings/credentials
+ * Returns masked credential status per platform.
+ */
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const result: Record<string, Record<string, string>> = {};
+
+  for (const [platform, cfg] of Object.entries(AGENT_ENV_MAP)) {
+    const envPath = path.join(cfg.dir, '.env');
+    result[platform] = {};
+
+    if (fs.existsSync(envPath)) {
+      const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+      for (const line of lines) {
+        const [key, ...rest] = line.split('=');
+        const val = rest.join('=').trim();
+        if (key && cfg.vars.includes(key.trim())) {
+          result[platform][key.trim()] = val ? maskValue(val) : '';
+        }
+      }
+    }
+
+    // Ensure all expected vars appear (empty if not set)
+    for (const v of cfg.vars) {
+      if (!(v in result[platform])) result[platform][v] = '';
+    }
+  }
+
+  return NextResponse.json({ credentials: result });
+}
+
+/**
+ * POST /api/settings/credentials
+ * Saves credentials to .env files and restarts agents.
+ * Body: { platform: 'twitter'|'reddit'|'discord'|'bluesky', credentials: { KEY: 'val', ... } }
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { platform, credentials } = body as { platform: string; credentials: Record<string, string> };
+
+    const cfg = AGENT_ENV_MAP[platform];
+    if (!cfg) {
+      return NextResponse.json({ error: `Unknown platform: ${platform}` }, { status: 400 });
+    }
+
+    // Merge with existing .env (only update provided non-empty keys)
+    const envPath = path.join(cfg.dir, '.env');
+    const existing: Record<string, string> = {};
+
+    if (fs.existsSync(envPath)) {
+      const lines = fs.readFileSync(envPath, 'utf8').split('\n');
+      for (const line of lines) {
+        const eq = line.indexOf('=');
+        if (eq > 0) {
+          existing[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
+        }
+      }
+    }
+
+    const merged: Record<string, string> = { ...existing };
+    for (const [k, v] of Object.entries(credentials)) {
+      if (v && v.trim()) merged[k] = v.trim();
+    }
+
+    fs.writeFileSync(envPath, buildEnvFile(merged), 'utf8');
+
+    // Restart the agent so it picks up new env vars
+    let restartResult = 'ok';
+    try {
+      await execAsync(`pm2 restart ${cfg.pm2Name}`);
+    } catch (err) {
+      restartResult = (err as Error).message;
+    }
+
+    // Log to DB audit
+    await query(
+      `INSERT INTO audit_events (event_type, entity_type, entity_id, metadata)
+       VALUES ('credentials.saved', 'platform', $1, $2)`,
+      [platform, JSON.stringify({ keys_set: Object.keys(credentials).filter(k => credentials[k]), restart: restartResult })]
+    ).catch(() => {});
+
+    return NextResponse.json({
+      platform,
+      keys_saved: Object.keys(merged).length,
+      restarted: restartResult === 'ok',
+      restart_output: restartResult === 'ok' ? undefined : restartResult,
+    });
+  } catch (err) {
+    console.error('[credentials] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
+  }
+}
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
new file mode 100644
index 0000000..f8c781f
--- /dev/null
+++ b/app/api/settings/route.ts
@@ -0,0 +1,86 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+/**
+ * GET /api/settings
+ * Return all app settings as a merged object.
+ */
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const result = await query(`SELECT key, value FROM app_settings ORDER BY key`);
+
+    const settings: Record<string, unknown> = {};
+    for (const row of result.rows) {
+      settings[row.key] = row.value;
+    }
+
+    // Also get data stats
+    const statsQueries = await Promise.all([
+      query(`SELECT COUNT(*)::int AS count FROM petitions`).catch(() => ({ rows: [{ count: 0 }] })),
+      query(`SELECT COUNT(*)::int AS count FROM signatures`).catch(() => ({ rows: [{ count: 0 }] })),
+      query(`SELECT COUNT(*)::int AS count FROM email_subscribers`).catch(() => ({ rows: [{ count: 0 }] })),
+      query(`SELECT COUNT(*)::int AS count FROM email_campaigns`).catch(() => ({ rows: [{ count: 0 }] })),
+    ]);
+
+    settings.data_stats = {
+      total_petitions: statsQueries[0].rows[0]?.count || 0,
+      total_signatures: statsQueries[1].rows[0]?.count || 0,
+      total_subscribers: statsQueries[2].rows[0]?.count || 0,
+      total_campaigns: statsQueries[3].rows[0]?.count || 0,
+      last_backup: new Date().toISOString(),
+    };
+
+    return NextResponse.json({ settings });
+  } catch (err) {
+    console.error('[api/settings] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
+  }
+}
+
+/**
+ * PATCH /api/settings
+ * Update one or more settings sections.
+ * Body: { org_profile?: {...}, petition_defaults?: {...}, campaign_settings?: {...}, ai_config?: {...} }
+ */
+export async function PATCH(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const allowedKeys = ['org_profile', 'petition_defaults', 'campaign_settings', 'ai_config'];
+    let updated = 0;
+
+    for (const key of allowedKeys) {
+      if (key in body && body[key] !== undefined) {
+        await query(
+          `INSERT INTO app_settings (key, value, updated_at)
+           VALUES ($1, $2, now())
+           ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
+          [key, JSON.stringify(body[key])]
+        );
+        updated++;
+      }
+    }
+
+    if (updated === 0) {
+      return NextResponse.json({ error: 'No valid settings to update' }, { status: 400 });
+    }
+
+    // Re-fetch all settings
+    const result = await query(`SELECT key, value FROM app_settings ORDER BY key`);
+    const settings: Record<string, unknown> = {};
+    for (const row of result.rows) {
+      settings[row.key] = row.value;
+    }
+
+    return NextResponse.json({ settings, updated });
+  } catch (err) {
+    console.error('[api/settings] PATCH error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
+  }
+}
diff --git a/app/api/signatures/route.ts b/app/api/signatures/route.ts
new file mode 100644
index 0000000..fd8c937
--- /dev/null
+++ b/app/api/signatures/route.ts
@@ -0,0 +1,106 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query, getClient } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const petitionId = url.searchParams.get('petition_id');
+
+  if (!petitionId) {
+    return NextResponse.json({ error: 'petition_id query param is required' }, { status: 400 });
+  }
+
+  try {
+    const result = await query(
+      `SELECT * FROM signatures WHERE petition_id = $1 ORDER BY created_at DESC`,
+      [petitionId]
+    );
+
+    const countResult = await query(
+      `SELECT COUNT(*)::int as total FROM signatures WHERE petition_id = $1`,
+      [petitionId]
+    );
+
+    return NextResponse.json({
+      rows: result.rows,
+      count: countResult.rows[0]?.total || 0,
+    });
+  } catch (err) {
+    console.error('[api/signatures] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch signatures' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const client = await getClient();
+
+  try {
+    const body = await request.json();
+    const { petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, opted_in_email } = body;
+
+    if (!petition_id || !signer_email) {
+      return NextResponse.json({ error: 'petition_id and signer_email are required' }, { status: 400 });
+    }
+
+    // Get IP address
+    const forwarded = request.headers.get('x-forwarded-for');
+    const ip = forwarded ? forwarded.split(',')[0].trim() : 'unknown';
+
+    await client.query('BEGIN');
+
+    // Insert signature (UNIQUE constraint on petition_id + signer_email handles dedup)
+    const sigResult = await client.query(
+      `INSERT INTO signatures (petition_id, signer_email, signer_name, signer_zip, signer_comment, is_public, ip_address, opted_in_email)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+       ON CONFLICT (petition_id, signer_email) DO NOTHING
+       RETURNING *`,
+      [
+        petition_id,
+        signer_email,
+        signer_name || null,
+        signer_zip || null,
+        signer_comment || null,
+        is_public !== false,
+        ip,
+        opted_in_email || false,
+      ]
+    );
+
+    if (sigResult.rows.length === 0) {
+      await client.query('ROLLBACK');
+      return NextResponse.json({ error: 'This email has already signed this petition' }, { status: 409 });
+    }
+
+    // Update petition signature_count
+    await client.query(
+      `UPDATE petitions SET signature_count = signature_count + 1 WHERE id = $1`,
+      [petition_id]
+    );
+
+    // If opted in, add to email_subscribers
+    if (opted_in_email) {
+      await client.query(
+        `INSERT INTO email_subscribers (email, name, zip_code, source, petition_ids)
+         VALUES ($1, $2, $3, 'petition', ARRAY[$4::uuid])
+         ON CONFLICT DO NOTHING`,
+        [signer_email, signer_name || null, signer_zip || null, petition_id]
+      );
+    }
+
+    await client.query('COMMIT');
+
+    return NextResponse.json({ row: sigResult.rows[0] }, { status: 201 });
+  } catch (err) {
+    await client.query('ROLLBACK');
+    console.error('[api/signatures] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to add signature' }, { status: 500 });
+  } finally {
+    client.release();
+  }
+}
diff --git a/app/api/subscribers/route.ts b/app/api/subscribers/route.ts
new file mode 100644
index 0000000..ce82352
--- /dev/null
+++ b/app/api/subscribers/route.ts
@@ -0,0 +1,176 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const search = url.searchParams.get('search');
+  const source = url.searchParams.get('source');
+
+  let sql = `SELECT * FROM email_subscribers`;
+  const params: unknown[] = [];
+  const conditions: string[] = [];
+
+  if (search) {
+    params.push(`%${search}%`);
+    conditions.push(`(email ILIKE $${params.length} OR name ILIKE $${params.length})`);
+  }
+
+  if (source && source !== 'all') {
+    params.push(source);
+    conditions.push(`source = $${params.length}`);
+  }
+
+  if (conditions.length > 0) {
+    sql += ` WHERE ` + conditions.join(' AND ');
+  }
+
+  sql += ` ORDER BY created_at DESC`;
+
+  try {
+    const result = await query(sql, params);
+
+    // Get stats
+    const statsResult = await query(`
+      SELECT
+        COUNT(*)::int as total,
+        COUNT(*) FILTER (WHERE is_active = true)::int as active,
+        COUNT(*) FILTER (WHERE is_active = false)::int as inactive
+      FROM email_subscribers
+    `);
+
+    const sourceResult = await query(`
+      SELECT source, COUNT(*)::int as count
+      FROM email_subscribers
+      GROUP BY source
+      ORDER BY count DESC
+    `);
+
+    const bySource: Record<string, number> = {};
+    for (const row of sourceResult.rows) {
+      bySource[row.source || 'unknown'] = row.count;
+    }
+
+    const stats = {
+      total: statsResult.rows[0]?.total || 0,
+      active: statsResult.rows[0]?.active || 0,
+      inactive: statsResult.rows[0]?.inactive || 0,
+      by_source: bySource,
+    };
+
+    return NextResponse.json({ rows: result.rows, stats });
+  } catch (err) {
+    console.error('[api/subscribers] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch subscribers' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { email, name, zip_code, source, tags } = body;
+
+    if (!email) {
+      return NextResponse.json({ error: 'Email is required' }, { status: 400 });
+    }
+
+    // Get user_id
+    const userResult = await query(`SELECT id FROM users LIMIT 1`);
+    const userId = userResult.rows[0]?.id || null;
+
+    const result = await query(
+      `INSERT INTO email_subscribers (user_id, email, name, zip_code, source, tags)
+       VALUES ($1, $2, $3, $4, $5, $6)
+       RETURNING *`,
+      [
+        userId,
+        email,
+        name || null,
+        zip_code || null,
+        source || 'manual',
+        tags || null,
+      ]
+    );
+
+    return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/subscribers] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to add subscriber' }, { status: 500 });
+  }
+}
+
+export async function PATCH(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { id, ...updates } = body;
+
+    if (!id) {
+      return NextResponse.json({ error: 'Subscriber id is required' }, { status: 400 });
+    }
+
+    const allowedFields = ['name', 'zip_code', 'source', 'tags', 'is_active', 'petition_ids'];
+    const setClauses: string[] = [];
+    const params: unknown[] = [];
+
+    for (const [key, value] of Object.entries(updates)) {
+      if (allowedFields.includes(key)) {
+        params.push(value);
+        setClauses.push(`${key} = $${params.length}`);
+      }
+    }
+
+    if (setClauses.length === 0) {
+      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
+    }
+
+    params.push(id);
+    const sql = `UPDATE email_subscribers SET ${setClauses.join(', ')} WHERE id = $${params.length} RETURNING *`;
+
+    const result = await query(sql, params);
+    if (result.rows.length === 0) {
+      return NextResponse.json({ error: 'Subscriber not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ row: result.rows[0] });
+  } catch (err) {
+    console.error('[api/subscribers] PATCH error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to update subscriber' }, { status: 500 });
+  }
+}
+
+export async function DELETE(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const url = new URL(request.url);
+    const id = url.searchParams.get('id');
+
+    if (!id) {
+      return NextResponse.json({ error: 'Subscriber id is required' }, { status: 400 });
+    }
+
+    const result = await query(
+      `UPDATE email_subscribers SET is_active = false, unsubscribed_at = NOW() WHERE id = $1 RETURNING *`,
+      [id]
+    );
+
+    if (result.rows.length === 0) {
+      return NextResponse.json({ error: 'Subscriber not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ row: result.rows[0] });
+  } catch (err) {
+    console.error('[api/subscribers] DELETE error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
+  }
+}
diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts
new file mode 100644
index 0000000..dd35f86
--- /dev/null
+++ b/app/api/templates/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const category = url.searchParams.get('category');
+
+  let sql = `SELECT * FROM petition_templates`;
+  const params: unknown[] = [];
+
+  if (category && category !== 'all') {
+    params.push(category);
+    sql += ` WHERE category = $${params.length}`;
+  }
+
+  sql += ` ORDER BY is_featured DESC, usage_count DESC, created_at DESC`;
+
+  try {
+    const result = await query(sql, params);
+    return NextResponse.json({ rows: result.rows });
+  } catch (err) {
+    console.error('[api/templates] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { title, category, body_html, body_text, tags, is_featured } = body;
+
+    if (!title || !body_html) {
+      return NextResponse.json({ error: 'Title and body_html are required' }, { status: 400 });
+    }
+
+    const result = await query(
+      `INSERT INTO petition_templates (title, category, body_html, body_text, tags, is_featured)
+       VALUES ($1, $2, $3, $4, $5, $6)
+       RETURNING *`,
+      [
+        title,
+        category || null,
+        body_html,
+        body_text || null,
+        tags || null,
+        is_featured || false,
+      ]
+    );
+
+    return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/templates] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create template' }, { status: 500 });
+  }
+}
diff --git a/app/api/trending/discover/route.ts b/app/api/trending/discover/route.ts
new file mode 100644
index 0000000..27a55f3
--- /dev/null
+++ b/app/api/trending/discover/route.ts
@@ -0,0 +1,201 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+import { callGemini } from '@/lib/gemini';
+
+/**
+ * POST /api/trending/discover
+ * Scans real Kalshi prediction markets + recent news articles from the DB,
+ * then uses Gemini AI to derive trending topics with auto-generated tags.
+ */
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    // 1. Gather REAL data from our sources
+    const [marketsRes, articlesRes] = await Promise.all([
+      // Top prediction markets by volume — real Kalshi data
+      query(`SELECT DISTINCT ON (title) title, category, volume, last_price, yes_bid, no_bid
+             FROM orbit_markets
+             WHERE volume > 10000
+             ORDER BY title, volume DESC
+             LIMIT 30`),
+      // Most recent news articles
+      query(`SELECT title, url, summary, sentiment, tags, published_at
+             FROM orbit_articles
+             ORDER BY published_at DESC
+             LIMIT 40`),
+    ]);
+
+    const markets = marketsRes.rows;
+    const articles = articlesRes.rows;
+
+    if (markets.length === 0 && articles.length === 0) {
+      return NextResponse.json({ error: 'No source data available. Sync markets first.' }, { status: 400 });
+    }
+
+    // 2. Build context from real data
+    const marketSummary = markets.map(m =>
+      `- "${m.title}" [${m.category}] volume=${m.volume} last_price=${m.last_price}¢`
+    ).join('\n');
+
+    const articleSummary = articles.slice(0, 25).map(a =>
+      `- "${a.title}" (${a.published_at ? new Date(a.published_at).toLocaleDateString() : 'recent'})`
+    ).join('\n');
+
+    // Get the actual categories from our market data
+    const categorySet = new Set(markets.map(m => m.category).filter(Boolean));
+    const realCategories = [...categorySet];
+
+    const prompt = `You are an AI trend analyst. Analyze the following REAL prediction market data and news articles to identify 8-12 compelling trending topics that would make strong petitions or advocacy campaigns.
+
+## REAL PREDICTION MARKETS (Kalshi):
+${marketSummary}
+
+## RECENT NEWS HEADLINES:
+${articleSummary}
+
+## INSTRUCTIONS:
+1. Derive topics ONLY from the real data above — do NOT invent topics
+2. Combine related markets + articles into unified trending themes
+3. Calculate engagement_score based on market volume + news coverage (40-95 scale)
+4. Generate 3-5 descriptive tags for each topic by analyzing the content
+5. Assign sentiment based on whether the topic is opportunity (positive), crisis (negative), debate (mixed), or informational (neutral)
+
+## REAL CATEGORIES to use: ${realCategories.join(', ')}
+You may also use: Economy, National Security, Technology, Environment, Healthcare, Social Issues, Foreign Policy
+
+Return a JSON array of objects with EXACTLY these fields:
+{
+  "title": "Short, compelling petition-ready title (max 80 chars)",
+  "content": "2-3 sentence description connecting market data + news context, explaining why this matters for advocacy",
+  "source": "Kalshi Markets" or "News" or "Kalshi + News" (based on where the data came from),
+  "source_url": null,
+  "engagement_score": 75,
+  "sentiment": "positive" | "negative" | "neutral" | "mixed",
+  "category": "one of the real categories listed above",
+  "tags": ["specific", "relevant", "descriptive", "tags", "from-content"]
+}
+
+IMPORTANT: Tags should be specific and derived from the actual content (e.g., "fed-chair-nomination", "greenland-acquisition", "iran-nuclear"), NOT generic (e.g., "politics", "trending").`;
+
+    type TrendingTopic = {
+      title: string;
+      content: string;
+      source: string;
+      source_url?: string;
+      engagement_score: number;
+      sentiment: string;
+      category: string;
+      tags: string[];
+    };
+
+    // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+    const r = await callGemini<TrendingTopic[]>({ prompt, maxTokens: 4096, temperature: 0.7 });
+    if (!r.ok) {
+      console.error('[trending/discover] gemini failed:', r.reason, r.detail);
+      return NextResponse.json(
+        { error: r.reason === 'no_key' ? 'AI service not configured' : 'AI discovery failed' },
+        { status: r.status },
+      );
+    }
+    const topics = r.data;
+
+    if (!Array.isArray(topics)) {
+      return NextResponse.json({ error: 'AI returned non-array data' }, { status: 502 });
+    }
+
+    // 3. Clear expired/old trending topics before inserting new ones
+    await query(`DELETE FROM trending_topics WHERE expires_at < NOW() OR created_at < NOW() - INTERVAL '7 days'`);
+
+    // 4. Insert each AI-derived topic
+    const inserted = [];
+    for (const topic of topics) {
+      try {
+        const result = await query(
+          `INSERT INTO trending_topics (source, source_url, title, content, engagement_score, sentiment, category, tags, expires_at)
+           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '7 days')
+           RETURNING *`,
+          [
+            topic.source || 'Kalshi + News',
+            topic.source_url || null,
+            topic.title,
+            topic.content || null,
+            topic.engagement_score || 50,
+            topic.sentiment || 'neutral',
+            topic.category || 'Politics',
+            topic.tags || null,
+          ]
+        );
+        inserted.push(result.rows[0]);
+      } catch (insertErr) {
+        console.error('[api/trending/discover] Insert error:', (insertErr as Error).message);
+      }
+    }
+
+    // 5. Also scan articles and tag them using AI (async, fire-and-forget)
+    tagArticlesInBackground().catch(err => {
+      console.error('[api/trending/discover] Background tag scan error:', err.message);
+    });
+
+    return NextResponse.json({
+      rows: inserted,
+      count: inserted.length,
+      sources: { markets: markets.length, articles: articles.length },
+    });
+  } catch (err) {
+    console.error('[api/trending/discover] Error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to discover trends' }, { status: 500 });
+  }
+}
+
+/**
+ * Background task: scan untagged articles and generate AI tags.
+ * Updates orbit_articles.tags for articles that have no tags.
+ */
+async function tagArticlesInBackground() {
+  const untagged = await query(
+    `SELECT id, title, summary FROM orbit_articles
+     WHERE tags IS NULL OR array_length(tags, 1) IS NULL
+     ORDER BY published_at DESC LIMIT 50`
+  );
+
+  if (untagged.rows.length === 0) return;
+
+  const articleList = untagged.rows.map(a =>
+    `ID:${a.id} | "${a.title}"${a.summary ? ' | ' + a.summary.slice(0, 100) : ''}`
+  ).join('\n');
+
+  const tagPrompt = `Analyze these news articles and generate 2-4 specific, descriptive tags for each one. Tags should be lowercase, hyphenated, and content-specific (e.g., "fed-chair-nomination", "iran-nuclear-deal", "greenland-sovereignty").
+
+Articles:
+${articleList}
+
+Return a JSON object where keys are article IDs and values are arrays of tags:
+{"uuid-here": ["tag1", "tag2", "tag3"], ...}`;
+
+  // 2026-05-05 (tick 29): migrated to lib/gemini.ts wrapper.
+  const tagR = await callGemini<Record<string, string[]>>({ prompt: tagPrompt, maxTokens: 4096, temperature: 0.3 });
+  if (!tagR.ok) {
+    console.error('[tag-scan] gemini failed:', tagR.reason);
+    return;
+  }
+  const tagMap = tagR.data;
+
+  let taggedCount = 0;
+  for (const [articleId, tags] of Object.entries(tagMap)) {
+    if (!Array.isArray(tags) || tags.length === 0) continue;
+    try {
+      await query(
+        `UPDATE orbit_articles SET tags = $1 WHERE id = $2`,
+        [tags, articleId]
+      );
+      taggedCount++;
+    } catch {
+      // Skip invalid UUIDs or missing articles
+    }
+  }
+
+  console.log(`[tag-scan] Tagged ${taggedCount}/${untagged.rows.length} articles`);
+}
diff --git a/app/api/trending/route.ts b/app/api/trending/route.ts
new file mode 100644
index 0000000..1b81c03
--- /dev/null
+++ b/app/api/trending/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+import { verifyAuth } from '@/lib/auth';
+
+export async function GET(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  const url = new URL(request.url);
+  const category = url.searchParams.get('category');
+
+  let sql = `SELECT * FROM trending_topics`;
+  const params: unknown[] = [];
+  const conditions: string[] = [];
+
+  if (category && category !== 'all') {
+    params.push(category);
+    conditions.push(`category = $${params.length}`);
+  }
+
+  if (conditions.length > 0) {
+    sql += ` WHERE ` + conditions.join(' AND ');
+  }
+
+  sql += ` ORDER BY engagement_score DESC NULLS LAST, created_at DESC`;
+
+  try {
+    const result = await query(sql, params);
+    return NextResponse.json({ rows: result.rows });
+  } catch (err) {
+    console.error('[api/trending] GET error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to fetch trending topics' }, { status: 500 });
+  }
+}
+
+export async function POST(request: NextRequest) {
+  const user = verifyAuth(request);
+  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+  try {
+    const body = await request.json();
+    const { source, source_url, title, content, engagement_score, sentiment, category, tags } = body;
+
+    if (!title || !source) {
+      return NextResponse.json({ error: 'Title and source are required' }, { status: 400 });
+    }
+
+    const result = await query(
+      `INSERT INTO trending_topics (source, source_url, title, content, engagement_score, sentiment, category, tags, expires_at)
+       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW() + INTERVAL '7 days')
+       RETURNING *`,
+      [
+        source,
+        source_url || null,
+        title,
+        content || null,
+        engagement_score || null,
+        sentiment || null,
+        category || null,
+        tags || null,
+      ]
+    );
+
+    return NextResponse.json({ row: result.rows[0] }, { status: 201 });
+  } catch (err) {
+    console.error('[api/trending] POST error:', (err as Error).message);
+    return NextResponse.json({ error: 'Failed to create trending topic' }, { status: 500 });
+  }
+}
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..566bddd
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,365 @@
+@import "tailwindcss";
+
+/* ─── Patty Design Tokens ─────────────────────────────────────────────────── */
+:root {
+  /* Backgrounds */
+  --color-bg:           #0a0a12;
+  --color-surface:      #12121e;
+  --color-surface-el:   #1a1a2e;
+
+  /* Brand — Violet */
+  --color-primary:      #7c3aed;
+  --color-primary-hover:#6d28d9;
+  --color-secondary:    #a78bfa;
+
+  /* Accent */
+  --color-accent:       #c084fc;
+
+  /* Semantic */
+  --color-success:      #22c55e;
+  --color-warning:      #f59e0b;
+  --color-error:        #ef4444;
+
+  /* Text */
+  --color-text:         #fafafa;
+  --color-text-secondary: #a1a1aa;
+  --color-text-muted:   #71717a;
+
+  /* Structure */
+  --color-border:       #2a2a3c;
+
+  /* Radius */
+  --radius-sm:  4px;
+  --radius-md:  8px;
+  --radius-lg:  12px;
+
+  /* Transition */
+  --transition: 150ms ease;
+}
+
+/* ─── Base ───────────────────────────────────────────────────────────────── */
+html {
+  scroll-behavior: smooth;
+  color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+  box-sizing: border-box;
+}
+
+body {
+  background-color: var(--color-bg);
+  color: var(--color-text);
+  font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+    "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  min-height: 100vh;
+}
+
+/* ─── Scrollbar ──────────────────────────────────────────────────────────── */
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background: var(--color-bg);
+}
+
+::-webkit-scrollbar-thumb {
+  background: var(--color-border);
+  border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: var(--color-text-muted);
+}
+
+/* ─── Cards ──────────────────────────────────────────────────────────────── */
+.card {
+  background-color: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-lg);
+  padding: 1rem;
+  transition: border-color var(--transition);
+}
+
+.card:hover {
+  border-color: #3a3a50;
+}
+
+.card-elevated {
+  background-color: var(--color-surface-el);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-lg);
+  padding: 1rem;
+}
+
+/* ─── Buttons ────────────────────────────────────────────────────────────── */
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.375rem;
+  padding: 0.5rem 1rem;
+  border-radius: var(--radius-md);
+  font-size: 0.875rem;
+  font-weight: 500;
+  line-height: 1.25rem;
+  cursor: pointer;
+  border: 1px solid transparent;
+  transition: background-color var(--transition), color var(--transition),
+    border-color var(--transition), opacity var(--transition);
+  white-space: nowrap;
+  user-select: none;
+}
+
+.btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+.btn:focus-visible {
+  outline: 2px solid var(--color-primary);
+  outline-offset: 2px;
+}
+
+.btn-primary {
+  background-color: var(--color-primary);
+  color: #fff;
+  border-color: var(--color-primary);
+}
+
+.btn-primary:hover:not(:disabled) {
+  background-color: var(--color-primary-hover);
+  border-color: var(--color-primary-hover);
+}
+
+.btn-secondary {
+  background-color: var(--color-surface-el);
+  color: var(--color-text);
+  border-color: var(--color-border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+  background-color: #22223a;
+  border-color: #3a3a50;
+}
+
+.btn-danger {
+  background-color: transparent;
+  color: var(--color-error);
+  border-color: var(--color-error);
+}
+
+.btn-danger:hover:not(:disabled) {
+  background-color: rgba(239, 68, 68, 0.12);
+}
+
+.btn-ghost {
+  background-color: transparent;
+  color: var(--color-text-secondary);
+  border-color: transparent;
+}
+
+.btn-ghost:hover:not(:disabled) {
+  background-color: var(--color-surface-el);
+  color: var(--color-text);
+}
+
+.btn-sm {
+  padding: 0.25rem 0.625rem;
+  font-size: 0.8125rem;
+  border-radius: var(--radius-sm);
+}
+
+.btn-lg {
+  padding: 0.75rem 1.5rem;
+  font-size: 1rem;
+}
+
+/* ─── Badges ─────────────────────────────────────────────────────────────── */
+.badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+  padding: 0.125rem 0.5rem;
+  border-radius: 9999px;
+  font-size: 0.75rem;
+  font-weight: 600;
+  line-height: 1rem;
+  letter-spacing: 0.02em;
+  text-transform: uppercase;
+}
+
+/* Status badges */
+.badge-draft {
+  background-color: rgba(161, 161, 170, 0.15);
+  color: var(--color-text-secondary);
+  border: 1px solid rgba(161, 161, 170, 0.3);
+}
+
+.badge-active {
+  background-color: rgba(34, 197, 94, 0.15);
+  color: var(--color-success);
+  border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.badge-paused {
+  background-color: rgba(245, 158, 11, 0.15);
+  color: var(--color-warning);
+  border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.badge-closed {
+  background-color: rgba(239, 68, 68, 0.15);
+  color: var(--color-error);
+  border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.badge-delivered {
+  background-color: rgba(124, 58, 237, 0.15);
+  color: var(--color-primary);
+  border: 1px solid rgba(124, 58, 237, 0.3);
+}
+
+.badge-success {
+  background-color: rgba(34, 197, 94, 0.15);
+  color: var(--color-success);
+  border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.badge-warning {
+  background-color: rgba(245, 158, 11, 0.15);
+  color: var(--color-warning);
+  border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.badge-error {
+  background-color: rgba(239, 68, 68, 0.15);
+  color: var(--color-error);
+  border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+/* ─── Form Inputs ────────────────────────────────────────────────────────── */
+.input {
+  width: 100%;
+  background-color: var(--color-surface-el);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-md);
+  padding: 0.5rem 0.75rem;
+  font-size: 0.875rem;
+  color: var(--color-text);
+  outline: none;
+  transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.input::placeholder {
+  color: var(--color-text-muted);
+}
+
+.input:focus {
+  border-color: var(--color-primary);
+  box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.2);
+}
+
+/* ─── Utility Helpers ────────────────────────────────────────────────────── */
+.divider {
+  border: none;
+  border-top: 1px solid var(--color-border);
+  margin: 0;
+}
+
+.text-primary   { color: var(--color-text); }
+.text-secondary { color: var(--color-text-secondary); }
+.text-muted     { color: var(--color-text-muted); }
+.text-brand     { color: var(--color-primary); }
+
+.surface    { background-color: var(--color-surface); }
+.surface-el { background-color: var(--color-surface-el); }
+
+/* Toast animation */
+@keyframes toast-in {
+  from { opacity: 0; transform: translateY(12px) scale(0.95); }
+  to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+/* Loading spinner */
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.spinner {
+  display: inline-block;
+  width: 1.25rem;
+  height: 1.25rem;
+  border: 2px solid var(--color-border);
+  border-top-color: var(--color-primary);
+  border-radius: 50%;
+  animation: spin 0.7s linear infinite;
+}
+
+/* ─── Skeleton Loaders ───────────────────────────────────────────────────── */
+.skeleton {
+  background: linear-gradient(90deg, var(--color-surface-el) 25%, var(--color-border) 50%, var(--color-surface-el) 75%);
+  background-size: 200% 100%;
+  animation: skeleton-shimmer 1.5s ease-in-out infinite;
+}
+
+@keyframes skeleton-shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+/* ─── Stat Card ──────────────────────────────────────────────────────────── */
+.stat-card {
+  background-color: var(--color-surface);
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-lg);
+  padding: 1.25rem;
+  transition: border-color var(--transition), transform var(--transition);
+}
+
+.stat-card:hover {
+  border-color: #3a3a50;
+  transform: translateY(-1px);
+}
+
+/* ─── Mobile Responsive Sidebar ──────────────────────────────────────────── */
+.sidebar-backdrop {
+  display: none;
+}
+
+@media (max-width: 768px) {
+  .sidebar-root {
+    position: fixed !important;
+    z-index: 40;
+    height: 100vh;
+    transform: translateX(-100%);
+    transition: transform 0.2s ease, width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+  }
+  .sidebar-root.sidebar-open {
+    transform: translateX(0);
+  }
+  #mobile-menu-btn {
+    display: flex !important;
+  }
+  .sidebar-backdrop {
+    display: block;
+  }
+}
+
+/* ─── Public Petition Page ──────────────────────────────────────────────── */
+@media (max-width: 860px) {
+  /* Stack the two-column layout on mobile/tablet */
+  .petition-layout {
+    flex-direction: column !important;
+  }
+  .petition-sidebar {
+    width: 100% !important;
+    position: static !important;
+  }
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..bf1b38a
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#7c3aed"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">📢</text>
+</svg>
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..7e6350d
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+  title: 'Pulse — News & Advocacy Dashboard',
+  description: 'Unified news intelligence, AI topic analysis, and petition generation platform',
+};
+
+export default function RootLayout({
+  children,
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  return (
+    <html lang="en" className="dark">
+      <body>
+        <div id="app-root">{children}</div>
+      </body>
+    </html>
+  );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..e0512b5
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+import { useRouter } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+export default function LoginPage() {
+  const router = useRouter();
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+  const [error, setError]       = useState('');
+  const [loading, setLoading]   = useState(false);
+
+  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault();
+    setError('');
+    setLoading(true);
+
+    try {
+      const res = await fetch('/api/auth/login', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ username, password }),
+      });
+
+      const data = await res.json();
+
+      if (res.ok && data.success) {
+        router.push('/');
+        router.refresh();
+      } else {
+        setError(data.error ?? 'Login failed. Please try again.');
+      }
+    } catch {
+      setError('Network error. Please try again.');
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  return (
+    <div
+      className="min-h-screen flex items-center justify-center px-4"
+      style={{ backgroundColor: 'var(--color-bg)' }}
+    >
+      <div className="w-full max-w-sm">
+        {/* Logo / Brand mark */}
+        <div className="text-center mb-8">
+          <div
+            className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
+            style={{
+              background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+              boxShadow: '0 4px 16px rgba(124, 58, 237, 0.3)',
+            }}
+          >
+            <span className="text-2xl font-black" style={{ color: '#fff' }}>
+              P
+            </span>
+          </div>
+          <h1 className="text-2xl font-bold" style={{ color: 'var(--color-text)' }}>
+            Patty
+          </h1>
+          <p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+            The Petition Specialist
+          </p>
+        </div>
+
+        {/* Card */}
+        <div
+          className="rounded-xl p-6"
+          style={{
+            backgroundColor: 'var(--color-surface)',
+            border: '1px solid var(--color-border)',
+          }}
+        >
+          <form onSubmit={handleSubmit} noValidate className="space-y-4">
+            {/* Error message */}
+            {error && (
+              <div
+                className="px-3 py-2.5 rounded-lg text-sm"
+                style={{
+                  backgroundColor: 'rgba(239,68,68,0.1)',
+                  border: '1px solid rgba(239,68,68,0.3)',
+                  color: 'var(--color-error)',
+                }}
+                role="alert"
+              >
+                {error}
+              </div>
+            )}
+
+            {/* Username */}
+            <div>
+              <label
+                htmlFor="username"
+                className="block text-sm font-medium mb-1.5"
+                style={{ color: 'var(--color-text-secondary)' }}
+              >
+                Username
+              </label>
+              <input
+                id="username"
+                type="text"
+                autoComplete="username"
+                autoFocus
+                required
+                className="input"
+                placeholder="admin"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                disabled={loading}
+              />
+            </div>
+
+            {/* Password */}
+            <div>
+              <label
+                htmlFor="password"
+                className="block text-sm font-medium mb-1.5"
+                style={{ color: 'var(--color-text-secondary)' }}
+              >
+                Password
+              </label>
+              <input
+                id="password"
+                type="password"
+                autoComplete="current-password"
+                required
+                className="input"
+                placeholder="Enter password"
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                disabled={loading}
+              />
+            </div>
+
+            {/* Submit */}
+            <button
+              type="submit"
+              disabled={loading || !username || !password}
+              className="btn btn-primary btn-lg w-full mt-2"
+            >
+              {loading ? (
+                <>
+                  <Loader2 size={16} className="animate-spin" />
+                  Signing in...
+                </>
+              ) : (
+                'Sign In'
+              )}
+            </button>
+          </form>
+        </div>
+
+        <p className="text-center text-xs mt-6" style={{ color: 'var(--color-text-muted)' }}>
+          Patty -- AI-Powered Petition Platform
+        </p>
+      </div>
+    </div>
+  );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..c6bdf56
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,5 @@
+import AppShell from '@/components/AppShell';
+
+export default function Home() {
+  return <AppShell />;
+}
diff --git a/app/petitions/[slug]/PostToPlatformsPanel.tsx b/app/petitions/[slug]/PostToPlatformsPanel.tsx
new file mode 100644
index 0000000..a09cd9f
--- /dev/null
+++ b/app/petitions/[slug]/PostToPlatformsPanel.tsx
@@ -0,0 +1,646 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+interface PlatformDef {
+  id: string;
+  label: string;
+  color: string;
+  icon: string;
+  hasConfig?: boolean;
+  configLabel?: string;
+  configPlaceholder?: string;
+  configDefault?: string;
+  configType?: 'select' | 'text';
+  configOptions?: string[];
+}
+
+interface PostLog {
+  id: string;
+  platform: string;
+  status: string;
+  error_msg: string | null;
+  actor: string | null;
+  posted_at: string | null;
+  created_at: string;
+  post_url: string | null;
+  platform_config: Record<string, string> | null;
+}
+
+interface PostToPlatformsPanelProps {
+  petitionSlug: string;
+  petitionTitle: string;
+  petitionId: string;
+}
+
+const PLATFORMS: PlatformDef[] = [
+  {
+    id: 'reddit',
+    label: 'Reddit',
+    color: '#FF4500',
+    icon: 'R',
+    hasConfig: true,
+    configLabel: 'Subreddit',
+    configType: 'select',
+    configOptions: [
+      'studentloans',
+      'StudentDebt',
+      'PSLF',
+      'personalfinance',
+      'politics',
+      'lostgeneration',
+      'antiwork',
+    ],
+    configDefault: 'studentloans',
+  },
+  {
+    id: 'discord',
+    label: 'Discord',
+    color: '#5865F2',
+    icon: 'D',
+    hasConfig: true,
+    configLabel: 'Channel ID',
+    configType: 'text',
+    configPlaceholder: 'e.g. 1234567890',
+  },
+  {
+    id: 'twitter',
+    label: 'X / Twitter',
+    color: '#1DA1F2',
+    icon: 'X',
+  },
+  {
+    id: 'bluesky',
+    label: 'Bluesky',
+    color: '#0085FF',
+    icon: 'B',
+  },
+  {
+    id: 'moveon',
+    label: 'MoveOn',
+    color: '#E03131',
+    icon: 'M',
+  },
+  {
+    id: 'patty',
+    label: 'Launch on Patty',
+    color: '#7c3aed',
+    icon: 'P',
+  },
+];
+
+export default function PostToPlatformsPanel({
+  petitionSlug,
+  petitionTitle,
+  petitionId,
+}: PostToPlatformsPanelProps) {
+  const [enabled, setEnabled] = useState<Record<string, boolean>>({});
+  const [configs, setConfigs] = useState<Record<string, Record<string, string>>>({});
+  const [posting, setPosting] = useState(false);
+  const [postResult, setPostResult] = useState<{
+    results: Array<{ platform: string; status: string; message: string }>;
+    summary: { total: number; posted: number; failed: number };
+  } | null>(null);
+  const [logs, setLogs] = useState<PostLog[]>([]);
+  const [logsLoading, setLogsLoading] = useState(true);
+  const [expanded, setExpanded] = useState(false);
+
+  // Initialize defaults
+  useEffect(() => {
+    const defaultEnabled: Record<string, boolean> = {};
+    const defaultConfigs: Record<string, Record<string, string>> = {};
+    for (const p of PLATFORMS) {
+      defaultEnabled[p.id] = false;
+      if (p.hasConfig && p.configDefault) {
+        defaultConfigs[p.id] = { [p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value']: p.configDefault };
+      }
+    }
+    setEnabled(defaultEnabled);
+    setConfigs(defaultConfigs);
+  }, []);
+
+  const fetchLogs = useCallback(async () => {
+    try {
+      const res = await fetch(`/api/petitions/${petitionSlug}/post-log`);
+      if (res.ok) {
+        const data = await res.json();
+        setLogs(data.logs || []);
+      }
+    } catch {
+      // silently fail
+    } finally {
+      setLogsLoading(false);
+    }
+  }, [petitionSlug]);
+
+  useEffect(() => {
+    fetchLogs();
+  }, [fetchLogs]);
+
+  function togglePlatform(id: string) {
+    setEnabled((prev) => ({ ...prev, [id]: !prev[id] }));
+  }
+
+  function selectAll() {
+    const next: Record<string, boolean> = {};
+    for (const p of PLATFORMS) next[p.id] = true;
+    setEnabled(next);
+  }
+
+  function selectNone() {
+    const next: Record<string, boolean> = {};
+    for (const p of PLATFORMS) next[p.id] = false;
+    setEnabled(next);
+  }
+
+  function updateConfig(platformId: string, key: string, value: string) {
+    setConfigs((prev) => ({
+      ...prev,
+      [platformId]: { ...prev[platformId], [key]: value },
+    }));
+  }
+
+  async function handlePost() {
+    const selectedPlatforms = PLATFORMS.filter((p) => enabled[p.id]).map((p) => ({
+      name: p.id,
+      enabled: true,
+      config: configs[p.id] || {},
+    }));
+
+    if (selectedPlatforms.length === 0) return;
+
+    setPosting(true);
+    setPostResult(null);
+
+    try {
+      const res = await fetch(`/api/petitions/${petitionSlug}/post`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ platforms: selectedPlatforms, action: 'post' }),
+      });
+
+      const data = await res.json();
+
+      if (res.ok) {
+        setPostResult({
+          results: data.results || [],
+          summary: data.summary || { total: 0, posted: 0, failed: 0 },
+        });
+        // Refresh logs
+        fetchLogs();
+      } else {
+        setPostResult({
+          results: [{ platform: 'system', status: 'failed', message: data.error || 'Request failed' }],
+          summary: { total: 1, posted: 0, failed: 1 },
+        });
+      }
+    } catch {
+      setPostResult({
+        results: [{ platform: 'system', status: 'failed', message: 'Network error' }],
+        summary: { total: 1, posted: 0, failed: 1 },
+      });
+    } finally {
+      setPosting(false);
+    }
+  }
+
+  const enabledCount = Object.values(enabled).filter(Boolean).length;
+
+  const statusColor = (status: string) => {
+    switch (status) {
+      case 'posted': return 'var(--color-success)';
+      case 'failed': return 'var(--color-error)';
+      case 'pending': return 'var(--color-warning)';
+      case 'skipped': return 'var(--color-text-muted)';
+      default: return 'var(--color-text-muted)';
+    }
+  };
+
+  const platformColor = (name: string) => {
+    const p = PLATFORMS.find((pl) => pl.id === name);
+    return p?.color || 'var(--color-text-muted)';
+  };
+
+  return (
+    <div style={{
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      borderRadius: 'var(--radius-lg)',
+      padding: '1.5rem',
+    }}>
+      {/* Header */}
+      <button
+        onClick={() => setExpanded(!expanded)}
+        style={{
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+          width: '100%',
+          background: 'none',
+          border: 'none',
+          cursor: 'pointer',
+          padding: 0,
+        }}
+      >
+        <h3 style={{
+          color: 'var(--color-text)',
+          fontSize: '1rem',
+          fontWeight: 700,
+          display: 'flex',
+          alignItems: 'center',
+          gap: '0.5rem',
+          margin: 0,
+        }}>
+          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+            <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
+            <polyline points="16 6 12 2 8 6"/>
+            <line x1="12" y1="2" x2="12" y2="15"/>
+          </svg>
+          Post to Platforms
+        </h3>
+        <svg
+          width="16"
+          height="16"
+          viewBox="0 0 24 24"
+          fill="none"
+          stroke="var(--color-text-muted)"
+          strokeWidth="2"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          style={{
+            transition: 'transform 150ms ease',
+            transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
+          }}
+        >
+          <polyline points="6 9 12 15 18 9"/>
+        </svg>
+      </button>
+
+      {/* Expandable Content */}
+      <div style={{
+        maxHeight: expanded ? '2000px' : '0',
+        overflow: 'hidden',
+        transition: 'max-height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
+      }}>
+        <div style={{ paddingTop: '1rem' }}>
+          {/* Platform toggles */}
+          <div style={{
+            display: 'flex',
+            flexWrap: 'wrap',
+            gap: '0.5rem',
+            marginBottom: '0.75rem',
+          }}>
+            {PLATFORMS.map((p) => (
+              <label
+                key={p.id}
+                style={{
+                  display: 'flex',
+                  alignItems: 'center',
+                  gap: '0.375rem',
+                  padding: '0.375rem 0.625rem',
+                  borderRadius: 'var(--radius-sm)',
+                  backgroundColor: enabled[p.id]
+                    ? `${p.color}18`
+                    : 'var(--color-surface-el)',
+                  border: `1px solid ${enabled[p.id] ? `${p.color}55` : 'var(--color-border)'}`,
+                  cursor: 'pointer',
+                  fontSize: '0.8125rem',
+                  fontWeight: 500,
+                  color: enabled[p.id] ? p.color : 'var(--color-text-secondary)',
+                  transition: 'all 150ms ease',
+                  userSelect: 'none',
+                }}
+              >
+                <input
+                  type="checkbox"
+                  checked={enabled[p.id] || false}
+                  onChange={() => togglePlatform(p.id)}
+                  style={{
+                    accentColor: p.color,
+                    cursor: 'pointer',
+                    width: '0.875rem',
+                    height: '0.875rem',
+                  }}
+                />
+                <span style={{
+                  width: '6px',
+                  height: '6px',
+                  borderRadius: '50%',
+                  backgroundColor: p.color,
+                  flexShrink: 0,
+                }} />
+                {p.label}
+              </label>
+            ))}
+          </div>
+
+          {/* All / None buttons */}
+          <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
+            <button
+              onClick={selectAll}
+              style={{
+                fontSize: '0.75rem',
+                padding: '0.125rem 0.5rem',
+                borderRadius: 'var(--radius-sm)',
+                border: '1px solid rgba(124, 58, 237, 0.4)',
+                background: 'transparent',
+                color: 'var(--color-primary)',
+                cursor: 'pointer',
+              }}
+            >
+              All
+            </button>
+            <button
+              onClick={selectNone}
+              style={{
+                fontSize: '0.75rem',
+                padding: '0.125rem 0.5rem',
+                borderRadius: 'var(--radius-sm)',
+                border: '1px solid var(--color-border)',
+                background: 'transparent',
+                color: 'var(--color-text-muted)',
+                cursor: 'pointer',
+              }}
+            >
+              None
+            </button>
+          </div>
+
+          {/* Platform-specific config fields */}
+          {PLATFORMS.filter((p) => p.hasConfig && enabled[p.id]).map((p) => {
+            const configKey = p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value';
+            return (
+              <div
+                key={p.id}
+                style={{
+                  display: 'flex',
+                  alignItems: 'center',
+                  gap: '0.5rem',
+                  marginBottom: '0.5rem',
+                  padding: '0.5rem 0.625rem',
+                  borderRadius: 'var(--radius-sm)',
+                  backgroundColor: `${p.color}08`,
+                  border: `1px solid ${p.color}22`,
+                }}
+              >
+                <span style={{
+                  width: '6px',
+                  height: '6px',
+                  borderRadius: '50%',
+                  backgroundColor: p.color,
+                  flexShrink: 0,
+                }} />
+                <span style={{
+                  fontSize: '0.75rem',
+                  color: 'var(--color-text-secondary)',
+                  minWidth: '60px',
+                  fontWeight: 500,
+                }}>
+                  {p.configLabel}:
+                </span>
+                {p.configType === 'select' && p.configOptions ? (
+                  <select
+                    value={configs[p.id]?.[configKey] || p.configDefault || ''}
+                    onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
+                    className="input"
+                    style={{
+                      flex: 1,
+                      padding: '0.25rem 0.5rem',
+                      fontSize: '0.8125rem',
+                    }}
+                  >
+                    {p.configOptions.map((opt) => (
+                      <option key={opt} value={opt}>
+                        r/{opt}
+                      </option>
+                    ))}
+                  </select>
+                ) : (
+                  <input
+                    type="text"
+                    className="input"
+                    placeholder={p.configPlaceholder || ''}
+                    value={configs[p.id]?.[configKey] || ''}
+                    onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
+                    style={{
+                      flex: 1,
+                      padding: '0.25rem 0.5rem',
+                      fontSize: '0.8125rem',
+                    }}
+                  />
+                )}
+              </div>
+            );
+          })}
+
+          {/* Review & Post button */}
+          <button
+            onClick={handlePost}
+            disabled={posting || enabledCount === 0}
+            className="btn btn-primary"
+            style={{
+              width: '100%',
+              padding: '0.75rem 1rem',
+              fontSize: '0.9375rem',
+              fontWeight: 700,
+              marginTop: '0.5rem',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              gap: '0.5rem',
+            }}
+          >
+            {posting ? (
+              <>
+                <span className="spinner" style={{ width: '1rem', height: '1rem', borderWidth: '2px' }} />
+                Posting...
+              </>
+            ) : (
+              <>
+                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                  <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
+                  <polyline points="16 6 12 2 8 6"/>
+                  <line x1="12" y1="2" x2="12" y2="15"/>
+                </svg>
+                Review &amp; Post ({enabledCount})
+              </>
+            )}
+          </button>
+
+          {/* Post results */}
+          {postResult && (
+            <div style={{
+              marginTop: '0.75rem',
+              padding: '0.75rem',
+              borderRadius: 'var(--radius-md)',
+              backgroundColor: 'var(--color-surface-el)',
+              border: '1px solid var(--color-border)',
+            }}>
+              <div style={{
+                display: 'flex',
+                justifyContent: 'space-between',
+                alignItems: 'center',
+                marginBottom: '0.5rem',
+              }}>
+                <span style={{
+                  fontSize: '0.75rem',
+                  fontWeight: 600,
+                  color: 'var(--color-text-secondary)',
+                  textTransform: 'uppercase',
+                  letterSpacing: '0.05em',
+                }}>
+                  Dispatch Results
+                </span>
+                <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
+                  {postResult.summary.posted}/{postResult.summary.total} posted
+                </span>
+              </div>
+              {postResult.results.map((r, i) => (
+                <div
+                  key={i}
+                  style={{
+                    display: 'flex',
+                    alignItems: 'center',
+                    gap: '0.5rem',
+                    padding: '0.375rem 0',
+                    fontSize: '0.8125rem',
+                    borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
+                  }}
+                >
+                  <span style={{
+                    width: '6px',
+                    height: '6px',
+                    borderRadius: '50%',
+                    backgroundColor: statusColor(r.status),
+                    flexShrink: 0,
+                  }} />
+                  <span style={{
+                    fontWeight: 600,
+                    color: platformColor(r.platform),
+                    minWidth: '70px',
+                    textTransform: 'capitalize',
+                  }}>
+                    {r.platform}
+                  </span>
+                  <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flex: 1 }}>
+                    {r.message}
+                  </span>
+                  <span className={`badge badge-${r.status === 'posted' ? 'success' : r.status === 'failed' ? 'error' : 'warning'}`}>
+                    {r.status}
+                  </span>
+                </div>
+              ))}
+            </div>
+          )}
+
+          {/* Post history log */}
+          <div style={{ marginTop: '1rem' }}>
+            <h4 style={{
+              fontSize: '0.8125rem',
+              fontWeight: 600,
+              color: 'var(--color-text-secondary)',
+              marginBottom: '0.5rem',
+              display: 'flex',
+              alignItems: 'center',
+              gap: '0.375rem',
+            }}>
+              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+                <circle cx="12" cy="12" r="10"/>
+                <polyline points="12 6 12 12 16 14"/>
+              </svg>
+              Posting History
+            </h4>
+
+            {logsLoading ? (
+              <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
+                <span className="spinner" style={{ width: '1.25rem', height: '1.25rem' }} />
+              </div>
+            ) : logs.length === 0 ? (
+              <p style={{
+                color: 'var(--color-text-muted)',
+                fontSize: '0.8125rem',
+                textAlign: 'center',
+                padding: '0.75rem 0',
+                fontStyle: 'italic',
+              }}>
+                No posts yet. Select platforms above and click Review &amp; Post.
+              </p>
+            ) : (
+              <div style={{
+                display: 'flex',
+                flexDirection: 'column',
+                gap: '0.375rem',
+                maxHeight: '200px',
+                overflowY: 'auto',
+              }}>
+                {logs.map((log) => (
+                  <div
+                    key={log.id}
+                    style={{
+                      display: 'flex',
+                      alignItems: 'center',
+                      gap: '0.5rem',
+                      padding: '0.375rem 0.5rem',
+                      borderRadius: 'var(--radius-sm)',
+                      backgroundColor: 'var(--color-surface-el)',
+                      border: '1px solid var(--color-border)',
+                      fontSize: '0.75rem',
+                    }}
+                  >
+                    <span style={{
+                      width: '6px',
+                      height: '6px',
+                      borderRadius: '50%',
+                      backgroundColor: platformColor(log.platform),
+                      flexShrink: 0,
+                    }} />
+                    <span style={{
+                      fontWeight: 600,
+                      color: platformColor(log.platform),
+                      minWidth: '55px',
+                      textTransform: 'capitalize',
+                    }}>
+                      {log.platform}
+                    </span>
+                    <span style={{
+                      color: statusColor(log.status),
+                      fontWeight: 600,
+                      minWidth: '42px',
+                      textTransform: 'capitalize',
+                    }}>
+                      {log.status}
+                    </span>
+                    <span style={{
+                      flex: 1,
+                      color: 'var(--color-text-muted)',
+                      overflow: 'hidden',
+                      textOverflow: 'ellipsis',
+                      whiteSpace: 'nowrap',
+                    }}>
+                      {log.error_msg || (log.actor ? `by ${log.actor}` : '')}
+                    </span>
+                    <span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
+                      {log.posted_at
+                        ? new Date(log.posted_at).toLocaleDateString('en-US', {
+                            month: 'short',
+                            day: 'numeric',
+                            hour: '2-digit',
+                            minute: '2-digit',
+                          })
+                        : new Date(log.created_at).toLocaleDateString('en-US', {
+                            month: 'short',
+                            day: 'numeric',
+                            hour: '2-digit',
+                            minute: '2-digit',
+                          })}
+                    </span>
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/app/petitions/[slug]/ShareButtons.tsx b/app/petitions/[slug]/ShareButtons.tsx
new file mode 100644
index 0000000..6d0a360
--- /dev/null
+++ b/app/petitions/[slug]/ShareButtons.tsx
@@ -0,0 +1,143 @@
+'use client';
+
+interface ShareButtonsProps {
+  url: string;
+  title: string;
+  summary: string;
+}
+
+export default function ShareButtons({ url, title, summary }: ShareButtonsProps) {
+  const encodedUrl = encodeURIComponent(url);
+  const encodedTitle = encodeURIComponent(title);
+  const encodedSummary = encodeURIComponent(summary || title);
+
+  const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(`${title} - Sign the petition!`)}&url=${encodedUrl}`;
+  const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
+  const emailUrl = `mailto:?subject=${encodedTitle}&body=${encodeURIComponent(`I just signed this petition and thought you might want to as well:\n\n${title}\n\n${summary ? summary + '\n\n' : ''}Sign here: ${url}`)}`;
+
+  const buttonBase: React.CSSProperties = {
+    display: 'inline-flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: '0.5rem',
+    padding: '0.625rem 1rem',
+    borderRadius: 'var(--radius-md)',
+    fontSize: '0.875rem',
+    fontWeight: 600,
+    textDecoration: 'none',
+    border: '1px solid var(--color-border)',
+    cursor: 'pointer',
+    transition: 'all 150ms ease',
+    flex: '1 1 0',
+    minWidth: '0',
+    textAlign: 'center',
+  };
+
+  return (
+    <div style={{
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      borderRadius: 'var(--radius-lg)',
+      padding: '1.5rem',
+    }}>
+      <h3 style={{
+        color: 'var(--color-text)',
+        fontSize: '1rem',
+        fontWeight: 700,
+        marginBottom: '1rem',
+      }}>
+        Share This Petition
+      </h3>
+
+      <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
+        {/* Twitter/X */}
+        <a
+          href={twitterUrl}
+          target="_blank"
+          rel="noopener noreferrer"
+          style={{
+            ...buttonBase,
+            backgroundColor: 'rgba(29, 155, 240, 0.12)',
+            color: '#1d9bf0',
+            borderColor: 'rgba(29, 155, 240, 0.3)',
+          }}
+          onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(29, 155, 240, 0.2)'; }}
+          onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(29, 155, 240, 0.12)'; }}
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+            <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
+          </svg>
+          <span>Post</span>
+        </a>
+
+        {/* Facebook */}
+        <a
+          href={facebookUrl}
+          target="_blank"
+          rel="noopener noreferrer"
+          style={{
+            ...buttonBase,
+            backgroundColor: 'rgba(66, 103, 178, 0.12)',
+            color: '#4267B2',
+            borderColor: 'rgba(66, 103, 178, 0.3)',
+          }}
+          onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(66, 103, 178, 0.2)'; }}
+          onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(66, 103, 178, 0.12)'; }}
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
+            <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
+          </svg>
+          <span>Share</span>
+        </a>
+
+        {/* Email */}
+        <a
+          href={emailUrl}
+          style={{
+            ...buttonBase,
+            backgroundColor: 'rgba(124, 58, 237, 0.12)',
+            color: 'var(--color-secondary)',
+            borderColor: 'rgba(124, 58, 237, 0.3)',
+          }}
+          onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(124, 58, 237, 0.2)'; }}
+          onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(124, 58, 237, 0.12)'; }}
+        >
+          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+            <rect x="2" y="4" width="20" height="16" rx="2"/>
+            <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
+          </svg>
+          <span>Email</span>
+        </a>
+      </div>
+
+      {/* Copy link */}
+      <button
+        onClick={() => {
+          navigator.clipboard.writeText(url).then(() => {
+            const btn = document.getElementById('copy-link-btn');
+            if (btn) {
+              btn.textContent = 'Link copied!';
+              setTimeout(() => { btn.textContent = 'Copy link to clipboard'; }, 2000);
+            }
+          });
+        }}
+        id="copy-link-btn"
+        style={{
+          display: 'block',
+          width: '100%',
+          marginTop: '0.75rem',
+          padding: '0.5rem',
+          backgroundColor: 'transparent',
+          border: '1px dashed var(--color-border)',
+          borderRadius: 'var(--radius-md)',
+          color: 'var(--color-text-muted)',
+          fontSize: '0.8125rem',
+          cursor: 'pointer',
+          transition: 'all 150ms ease',
+        }}
+      >
+        Copy link to clipboard
+      </button>
+    </div>
+  );
+}
diff --git a/app/petitions/[slug]/SignatureForm.tsx b/app/petitions/[slug]/SignatureForm.tsx
new file mode 100644
index 0000000..b4b138a
--- /dev/null
+++ b/app/petitions/[slug]/SignatureForm.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import { useState, FormEvent } from 'react';
+
+interface SignatureFormProps {
+  petitionId: string;
+  petitionTitle: string;
+  isActive: boolean;
+}
+
+export default function SignatureForm({ petitionId, petitionTitle, isActive }: SignatureFormProps) {
+  const [name, setName] = useState('');
+  const [email, setEmail] = useState('');
+  const [zip, setZip] = useState('');
+  const [comment, setComment] = useState('');
+  const [optedIn, setOptedIn] = useState(false);
+  const [honeypot, setHoneypot] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [result, setResult] = useState<{ success: boolean; message: string; signatureCount?: number } | null>(null);
+
+  if (!isActive) {
+    return (
+      <div style={{
+        backgroundColor: 'var(--color-surface)',
+        border: '1px solid var(--color-border)',
+        borderRadius: 'var(--radius-lg)',
+        padding: '2rem',
+        textAlign: 'center',
+      }}>
+        <div style={{ fontSize: '2rem', marginBottom: '0.75rem' }}>
+          {/* Lock icon via SVG */}
+          <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block' }}>
+            <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
+            <path d="M7 11V7a5 5 0 0 1 10 0v4" />
+          </svg>
+        </div>
+        <h3 style={{ color: 'var(--color-text)', fontSize: '1.125rem', fontWeight: 600, marginBottom: '0.5rem' }}>
+          This petition is not currently accepting signatures
+        </h3>
+        <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>
+          The petition may be under review, paused, or has been closed. Check back later.
+        </p>
+      </div>
+    );
+  }
+
+  async function handleSubmit(e: FormEvent) {
+    e.preventDefault();
+    if (honeypot) return; // bot detected
+
+    if (!name.trim()) {
+      setResult({ success: false, message: 'Please enter your name.' });
+      return;
+    }
+    if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+      setResult({ success: false, message: 'Please enter a valid email address.' });
+      return;
+    }
+
+    setSubmitting(true);
+    setResult(null);
+
+    try {
+      const res = await fetch('/api/public/signatures', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          petition_id: petitionId,
+          signer_name: name.trim(),
+          signer_email: email.trim().toLowerCase(),
+          signer_zip: zip.trim() || null,
+          signer_comment: comment.trim() || null,
+          opted_in_email: optedIn,
+          _hp: honeypot, // honeypot field
+        }),
+      });
+
+      const data = await res.json();
+
+      if (res.ok) {
+        setResult({
+          success: true,
+          message: `Thank you for signing! You are signature #${data.signatureCount}.`,
+          signatureCount: data.signatureCount,
+        });
+        // Reset form
+        setName('');
+        setEmail('');
+        setZip('');
+        setComment('');
+        setOptedIn(false);
+      } else {
+        setResult({
+          success: false,
+          message: data.error || 'Something went wrong. Please try again.',
+        });
+      }
+    } catch {
+      setResult({
+        success: false,
+        message: 'Network error. Please check your connection and try again.',
+      });
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  return (
+    <div style={{
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      borderRadius: 'var(--radius-lg)',
+      padding: '1.5rem',
+    }}>
+      <h3 style={{
+        color: 'var(--color-text)',
+        fontSize: '1.25rem',
+        fontWeight: 700,
+        marginBottom: '1.25rem',
+      }}>
+        Sign This Petition
+      </h3>
+
+      {result && (
+        <div style={{
+          padding: '0.75rem 1rem',
+          borderRadius: 'var(--radius-md)',
+          marginBottom: '1rem',
+          backgroundColor: result.success ? 'rgba(34, 197, 94, 0.15)' : 'rgba(239, 68, 68, 0.15)',
+          border: `1px solid ${result.success ? 'rgba(34, 197, 94, 0.3)' : 'rgba(239, 68, 68, 0.3)'}`,
+          color: result.success ? 'var(--color-success)' : 'var(--color-error)',
+          fontSize: '0.875rem',
+        }}>
+          {result.message}
+        </div>
+      )}
+
+      {result?.success ? (
+        <div style={{ textAlign: 'center', padding: '1rem 0' }}>
+          <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-success)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', marginBottom: '0.75rem' }}>
+            <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
+            <polyline points="22 4 12 14.01 9 11.01" />
+          </svg>
+          <p style={{ color: 'var(--color-text)', fontWeight: 600, fontSize: '1rem', marginBottom: '0.5rem' }}>
+            Your signature has been recorded!
+          </p>
+          <p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
+            Share this petition to help it reach its goal.
+          </p>
+        </div>
+      ) : (
+        <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
+          {/* Honeypot - hidden from humans */}
+          <div style={{ position: 'absolute', left: '-9999px', opacity: 0, height: 0, overflow: 'hidden' }} aria-hidden="true">
+            <label htmlFor="website_url">Website</label>
+            <input
+              id="website_url"
+              name="website_url"
+              type="text"
+              tabIndex={-1}
+              autoComplete="off"
+              value={honeypot}
+              onChange={(e) => setHoneypot(e.target.value)}
+            />
+          </div>
+
+          <div>
+            <label htmlFor="signer_name" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+              Full Name <span style={{ color: 'var(--color-error)' }}>*</span>
+            </label>
+            <input
+              id="signer_name"
+              type="text"
+              className="input"
+              placeholder="Your full name"
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              required
+              disabled={submitting}
+            />
+          </div>
+
+          <div>
+            <label htmlFor="signer_email" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+              Email Address <span style={{ color: 'var(--color-error)' }}>*</span>
+            </label>
+            <input
+              id="signer_email"
+              type="email"
+              className="input"
+              placeholder="your@email.com"
+              value={email}
+              onChange={(e) => setEmail(e.target.value)}
+              required
+              disabled={submitting}
+            />
+            <p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>
+              Your email is used for verification only and will not be displayed publicly.
+            </p>
+          </div>
+
+          <div>
+            <label htmlFor="signer_zip" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+              ZIP Code <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>(optional)</span>
+            </label>
+            <input
+              id="signer_zip"
+              type="text"
+              className="input"
+              placeholder="e.g. 90210"
+              value={zip}
+              onChange={(e) => setZip(e.target.value)}
+              maxLength={10}
+              disabled={submitting}
+              style={{ maxWidth: '12rem' }}
+            />
+          </div>
+
+          <div>
+            <label htmlFor="signer_comment" style={{ display: 'block', color: 'var(--color-text-secondary)', fontSize: '0.875rem', fontWeight: 500, marginBottom: '0.375rem' }}>
+              Comment <span style={{ color: 'var(--color-text-muted)', fontWeight: 400 }}>(optional)</span>
+            </label>
+            <textarea
+              id="signer_comment"
+              className="input"
+              placeholder="Why are you signing this petition?"
+              value={comment}
+              onChange={(e) => setComment(e.target.value)}
+              rows={3}
+              maxLength={1000}
+              disabled={submitting}
+              style={{ resize: 'vertical', minHeight: '4rem' }}
+            />
+          </div>
+
+          <div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem' }}>
+            <input
+              id="opted_in_email"
+              type="checkbox"
+              checked={optedIn}
+              onChange={(e) => setOptedIn(e.target.checked)}
+              disabled={submitting}
+              style={{
+                marginTop: '0.125rem',
+                accentColor: 'var(--color-primary)',
+                width: '1rem',
+                height: '1rem',
+                cursor: 'pointer',
+              }}
+            />
+            <label htmlFor="opted_in_email" style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', cursor: 'pointer' }}>
+              Keep me updated on this petition and related campaigns
+            </label>
+          </div>
+
+          <button
+            type="submit"
+            disabled={submitting}
+            className="btn btn-primary btn-lg"
+            style={{
+              width: '100%',
+              fontSize: '1rem',
+              fontWeight: 700,
+              padding: '0.875rem 1.5rem',
+              marginTop: '0.25rem',
+            }}
+          >
+            {submitting ? (
+              <span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}>
+                <span className="spinner" style={{ width: '1rem', height: '1rem', borderWidth: '2px' }} />
+                Signing...
+              </span>
+            ) : (
+              'Sign This Petition'
+            )}
+          </button>
+
+          <p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', textAlign: 'center', lineHeight: 1.4 }}>
+            By signing, you agree that your name may be publicly listed as a supporter. Your email will never be shared.
+          </p>
+        </form>
+      )}
+    </div>
+  );
+}
diff --git a/app/petitions/[slug]/page.tsx b/app/petitions/[slug]/page.tsx
new file mode 100644
index 0000000..2989f49
--- /dev/null
+++ b/app/petitions/[slug]/page.tsx
@@ -0,0 +1,510 @@
+import { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { query } from '@/lib/db';
+import { sanitizePetitionBody } from '@/lib/sanitize';
+import SignatureForm from './SignatureForm';
+import ShareButtons from './ShareButtons';
+import PostToPlatformsPanel from './PostToPlatformsPanel';
+
+interface PetitionRow {
+  id: string;
+  title: string;
+  slug: string;
+  summary: string | null;
+  body_html: string;
+  body_text: string | null;
+  target: string | null;
+  category: string | null;
+  tags: string[] | null;
+  status: string;
+  signature_count: number;
+  signature_goal: number;
+  share_count: number;
+  image_url: string | null;
+  created_at: string;
+  updated_at: string;
+}
+
+interface PageProps {
+  params: Promise<{ slug: string }>;
+}
+
+async function getPetition(slug: string): Promise<PetitionRow | null> {
+  const result = await query<PetitionRow>(
+    `SELECT id, title, slug, summary, body_html, body_text, target, category, tags,
+            status, signature_count, signature_goal, share_count, image_url,
+            created_at, updated_at
+     FROM petitions WHERE slug = $1 LIMIT 1`,
+    [slug]
+  );
+  return result.rows[0] || null;
+}
+
+async function getRecentSignatures(petitionId: string) {
+  const result = await query(
+    `SELECT signer_name, signer_comment, created_at
+     FROM signatures
+     WHERE petition_id = $1 AND is_public = true
+     ORDER BY created_at DESC
+     LIMIT 10`,
+    [petitionId]
+  );
+  return result.rows;
+}
+
+/**
+ * Sanitize admin-authored HTML to strip script tags and event handlers.
+ * body_html is authored by authenticated admins only (via PATCH/POST endpoints),
+ * but we apply basic sanitization as defense-in-depth.
+ */
+// 2026-05-05 (P1 architect-review + tick 27): the handwritten regex-only
+// sanitizer was bypassable (SVG, data: URIs, mathml, malformed nesting).
+// Replaced with the shared allowlist from lib/sanitize.ts, which is the
+// SAME allowlist applied at POST/PATCH boundary in api/petitions/route.ts.
+// Drift between the two layers is exactly the gap stored XSS slips through.
+
+export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
+  const { slug } = await params;
+  const petition = await getPetition(slug);
+
+  if (!petition) {
+    return { title: 'Petition Not Found' };
+  }
+
+  const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+
+  return {
+    title: `${petition.title} | Patty Petitions`,
+    description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+    openGraph: {
+      title: petition.title,
+      description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+      url: publicUrl,
+      type: 'website',
+      siteName: 'Patty - The Petition Specialist',
+      ...(petition.image_url ? { images: [{ url: petition.image_url }] } : {}),
+    },
+    twitter: {
+      card: 'summary_large_image',
+      title: petition.title,
+      description: petition.summary || petition.body_text?.slice(0, 160) || petition.title,
+    },
+  };
+}
+
+export default async function PetitionPage({ params }: PageProps) {
+  const { slug } = await params;
+  const petition = await getPetition(slug);
+
+  if (!petition) {
+    notFound();
+  }
+
+  const isActive = petition.status === 'active';
+  const signatures = await getRecentSignatures(petition.id);
+  const publicUrl = `http://45.61.58.125:7460/petitions/${petition.slug}`;
+  const progressPercent = petition.signature_goal > 0
+    ? Math.min(100, Math.round((petition.signature_count / petition.signature_goal) * 100))
+    : 0;
+
+  const formattedDate = new Date(petition.created_at).toLocaleDateString('en-US', {
+    year: 'numeric',
+    month: 'long',
+    day: 'numeric',
+  });
+
+  const sanitizedBody = sanitizePetitionBody(petition.body_html);
+
+  return (
+    <div style={{
+      minHeight: '100vh',
+      backgroundColor: 'var(--color-bg)',
+      color: 'var(--color-text)',
+    }}>
+      {/* Header */}
+      <header style={{
+        borderBottom: '1px solid var(--color-border)',
+        backgroundColor: 'var(--color-surface)',
+      }}>
+        <div style={{
+          maxWidth: '1100px',
+          margin: '0 auto',
+          padding: '1rem 1.5rem',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+        }}>
+          <a href="/" style={{
+            color: 'var(--color-primary)',
+            textDecoration: 'none',
+            fontWeight: 700,
+            fontSize: '1.125rem',
+            display: 'flex',
+            alignItems: 'center',
+            gap: '0.5rem',
+          }}>
+            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+              <path d="m3 11 18-5v12L3 13z"/>
+              <path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>
+            </svg>
+            Patty
+          </a>
+          <span style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
+            Petition Platform
+          </span>
+        </div>
+      </header>
+
+      {/* Main content */}
+      <main style={{
+        maxWidth: '1100px',
+        margin: '0 auto',
+        padding: '2rem 1.5rem 4rem',
+      }}>
+        {/* Petition status banner */}
+        {!isActive && (
+          <div style={{
+            padding: '0.75rem 1rem',
+            borderRadius: 'var(--radius-md)',
+            marginBottom: '1.5rem',
+            backgroundColor: petition.status === 'closed' || petition.status === 'delivered'
+              ? 'rgba(239, 68, 68, 0.1)'
+              : 'rgba(245, 158, 11, 0.1)',
+            border: `1px solid ${
+              petition.status === 'closed' || petition.status === 'delivered'
+                ? 'rgba(239, 68, 68, 0.3)'
+                : 'rgba(245, 158, 11, 0.3)'
+            }`,
+            color: petition.status === 'closed' || petition.status === 'delivered'
+              ? 'var(--color-error)'
+              : 'var(--color-warning)',
+            fontSize: '0.875rem',
+            fontWeight: 500,
+            textAlign: 'center',
+          }}>
+            {petition.status === 'delivered'
+              ? 'This petition has been delivered to its target.'
+              : petition.status === 'closed'
+              ? 'This petition has been closed.'
+              : petition.status === 'paused'
+              ? 'This petition is currently paused.'
+              : 'This petition is not yet active.'}
+          </div>
+        )}
+
+        <div style={{
+          display: 'grid',
+          gridTemplateColumns: '1fr',
+          gap: '2rem',
+        }}>
+          {/* Desktop: two-column layout */}
+          <div className="petition-layout" style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
+            {/* Left column - petition content */}
+            <div style={{ flex: '1 1 600px', minWidth: 0 }}>
+              {/* Category + tags */}
+              <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '1rem' }}>
+                {petition.category && (
+                  <span className="badge badge-active" style={{ textTransform: 'capitalize' }}>
+                    {petition.category}
+                  </span>
+                )}
+                {petition.tags?.map((rawTag) => {
+                  const tag = rawTag.replace(/^["'{]+|["'}]+$/g, '').trim();
+                  if (!tag) return null;
+                  return (
+                    <span key={tag} style={{
+                      display: 'inline-block',
+                      padding: '0.125rem 0.5rem',
+                      borderRadius: '9999px',
+                      fontSize: '0.75rem',
+                      fontWeight: 500,
+                      backgroundColor: 'rgba(124, 58, 237, 0.12)',
+                      color: 'var(--color-secondary)',
+                      border: '1px solid rgba(124, 58, 237, 0.25)',
+                    }}>
+                      {tag}
+                    </span>
+                  );
+                })}
+              </div>
+
+              {/* Title */}
+              <h1 style={{
+                fontSize: 'clamp(1.5rem, 4vw, 2.25rem)',
+                fontWeight: 800,
+                lineHeight: 1.2,
+                color: 'var(--color-text)',
+                marginBottom: '0.75rem',
+              }}>
+                {petition.title}
+              </h1>
+
+              {/* Meta */}
+              <div style={{
+                display: 'flex',
+                flexWrap: 'wrap',
+                gap: '1rem',
+                alignItems: 'center',
+                marginBottom: '1.5rem',
+                fontSize: '0.875rem',
+                color: 'var(--color-text-muted)',
+              }}>
+                <span>Started {formattedDate}</span>
+                {petition.target && (
+                  <>
+                    <span style={{ color: 'var(--color-border)' }}>|</span>
+                    <span>
+                      Directed to: <strong style={{ color: 'var(--color-text-secondary)' }}>{petition.target}</strong>
+                    </span>
+                  </>
+                )}
+              </div>
+
+              {/* Summary */}
+              {petition.summary && (
+                <p style={{
+                  fontSize: '1.0625rem',
+                  lineHeight: 1.7,
+                  color: 'var(--color-text-secondary)',
+                  marginBottom: '1.5rem',
+                  padding: '1rem 1.25rem',
+                  backgroundColor: 'var(--color-surface)',
+                  borderLeft: '3px solid var(--color-primary)',
+                  borderRadius: '0 var(--radius-md) var(--radius-md) 0',
+                }}>
+                  {petition.summary}
+                </p>
+              )}
+
+              {/* Body HTML — admin-authored content, sanitized as defense-in-depth */}
+              <div
+                dangerouslySetInnerHTML={{ __html: sanitizedBody }}
+                style={{
+                  lineHeight: 1.8,
+                  fontSize: '1rem',
+                  color: 'var(--color-text-secondary)',
+                }}
+                className="petition-body"
+              />
+
+              {/* Recent signatures */}
+              {signatures.length > 0 && (
+                <div style={{ marginTop: '2.5rem' }}>
+                  <h3 style={{
+                    color: 'var(--color-text)',
+                    fontSize: '1.125rem',
+                    fontWeight: 700,
+                    marginBottom: '1rem',
+                  }}>
+                    Recent Supporters
+                  </h3>
+                  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
+                    {signatures.map((sig, i) => (
+                      <div key={i} style={{
+                        padding: '0.75rem 1rem',
+                        backgroundColor: 'var(--color-surface)',
+                        border: '1px solid var(--color-border)',
+                        borderRadius: 'var(--radius-md)',
+                      }}>
+                        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                          <span style={{ fontWeight: 600, color: 'var(--color-text)', fontSize: '0.875rem' }}>
+                            {sig.signer_name || 'Anonymous'}
+                          </span>
+                          <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>
+                            {new Date(sig.created_at).toLocaleDateString('en-US', {
+                              month: 'short',
+                              day: 'numeric',
+                            })}
+                          </span>
+                        </div>
+                        {sig.signer_comment && (
+                          <p style={{
+                            color: 'var(--color-text-secondary)',
+                            fontSize: '0.8125rem',
+                            marginTop: '0.375rem',
+                            fontStyle: 'italic',
+                          }}>
+                            &ldquo;{sig.signer_comment}&rdquo;
+                          </p>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </div>
+
+            {/* Right column - signature form + progress + share */}
+            <div className="petition-sidebar" style={{
+              flex: '0 0 auto',
+              width: '360px',
+              maxWidth: '100%',
+              display: 'flex',
+              flexDirection: 'column',
+              gap: '1.5rem',
+              alignSelf: 'flex-start',
+              position: 'sticky',
+              top: '1.5rem',
+            }}>
+              {/* Progress card */}
+              <div style={{
+                backgroundColor: 'var(--color-surface)',
+                border: '1px solid var(--color-border)',
+                borderRadius: 'var(--radius-lg)',
+                padding: '1.5rem',
+              }}>
+                <div style={{
+                  display: 'flex',
+                  justifyContent: 'space-between',
+                  alignItems: 'baseline',
+                  marginBottom: '0.75rem',
+                }}>
+                  <span style={{
+                    fontSize: '2rem',
+                    fontWeight: 800,
+                    color: 'var(--color-primary)',
+                  }}>
+                    {petition.signature_count.toLocaleString()}
+                  </span>
+                  <span style={{
+                    color: 'var(--color-text-muted)',
+                    fontSize: '0.875rem',
+                  }}>
+                    of {petition.signature_goal.toLocaleString()} goal
+                  </span>
+                </div>
+
+                {/* Progress bar */}
+                <div style={{
+                  width: '100%',
+                  height: '8px',
+                  backgroundColor: 'var(--color-surface-el)',
+                  borderRadius: '4px',
+                  overflow: 'hidden',
+                  marginBottom: '0.5rem',
+                }}>
+                  <div style={{
+                    width: `${progressPercent}%`,
+                    height: '100%',
+                    backgroundColor: 'var(--color-primary)',
+                    borderRadius: '4px',
+                    transition: 'width 0.5s ease',
+                    minWidth: progressPercent > 0 ? '4px' : '0',
+                  }} />
+                </div>
+                <p style={{
+                  color: 'var(--color-text-muted)',
+                  fontSize: '0.8125rem',
+                  textAlign: 'right',
+                }}>
+                  {progressPercent}% complete
+                </p>
+              </div>
+
+              {/* Signature form */}
+              <SignatureForm
+                petitionId={petition.id}
+                petitionTitle={petition.title}
+                isActive={isActive}
+              />
+
+              {/* Share buttons */}
+              <ShareButtons
+                url={publicUrl}
+                title={petition.title}
+                summary={petition.summary || ''}
+              />
+
+              {/* Post to Platforms (admin review panel) */}
+              <PostToPlatformsPanel
+                petitionSlug={petition.slug}
+                petitionTitle={petition.title}
+                petitionId={petition.id}
+              />
+            </div>
+          </div>
+        </div>
+      </main>
+
+      {/* Footer */}
+      <footer style={{
+        borderTop: '1px solid var(--color-border)',
+        backgroundColor: 'var(--color-surface)',
+        padding: '1.5rem',
+        textAlign: 'center',
+      }}>
+        <p style={{ color: 'var(--color-text-muted)', fontSize: '0.8125rem' }}>
+          Powered by <strong style={{ color: 'var(--color-primary)' }}>Patty</strong> - The Petition Specialist
+        </p>
+      </footer>
+
+      {/* Petition body styles */}
+      <style>{`
+        .petition-body h1,
+        .petition-body h2,
+        .petition-body h3,
+        .petition-body h4 {
+          color: var(--color-text);
+          margin-top: 1.5em;
+          margin-bottom: 0.75em;
+          font-weight: 700;
+          line-height: 1.3;
+        }
+        .petition-body h1 { font-size: 1.75rem; }
+        .petition-body h2 { font-size: 1.5rem; }
+        .petition-body h3 { font-size: 1.25rem; }
+        .petition-body h4 { font-size: 1.125rem; }
+        .petition-body p {
+          margin-bottom: 1em;
+        }
+        .petition-body ul,
+        .petition-body ol {
+          margin-bottom: 1em;
+          padding-left: 1.5em;
+        }
+        .petition-body li {
+          margin-bottom: 0.375em;
+        }
+        .petition-body a {
+          color: var(--color-secondary);
+          text-decoration: underline;
+          text-underline-offset: 2px;
+        }
+        .petition-body a:hover {
+          color: var(--color-primary);
+        }
+        .petition-body blockquote {
+          border-left: 3px solid var(--color-primary);
+          padding: 0.75rem 1rem;
+          margin: 1em 0;
+          background-color: var(--color-surface);
+          border-radius: 0 var(--radius-md) var(--radius-md) 0;
+          font-style: italic;
+          color: var(--color-text-secondary);
+        }
+        .petition-body strong,
+        .petition-body b {
+          color: var(--color-text);
+          font-weight: 700;
+        }
+        .petition-body img {
+          max-width: 100%;
+          border-radius: var(--radius-md);
+          margin: 1em 0;
+        }
+        .petition-body hr {
+          border: none;
+          border-top: 1px solid var(--color-border);
+          margin: 2em 0;
+        }
+
+        /* Responsive: stack columns on mobile */
+        @media (max-width: 768px) {
+          .petition-body {
+            font-size: 0.9375rem !important;
+          }
+        }
+      `}</style>
+    </div>
+  );
+}
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
new file mode 100644
index 0000000..dd08b57
--- /dev/null
+++ b/components/AppShell.tsx
@@ -0,0 +1,232 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { LogOut, Menu, LayoutGrid } from 'lucide-react';
+import { AuthProvider, useAuth } from './AuthProvider';
+import { ToastProvider } from './ToastProvider';
+import Sidebar, { type TabId } from './Sidebar';
+import DashboardTab from './dashboard/DashboardTab';
+import NewsFeedTab from './news/NewsFeedTab';
+import TopicsTab from './topics/TopicsTab';
+import PetitionsTab from './petitions/PetitionsTab';
+import GrantsTab from './grants/GrantsTab';
+import HeatMapTab from './heatmap/HeatMapTab';
+import CampaignsTab from './campaigns/CampaignsTab';
+import SourcesTab from './sources/SourcesTab';
+import SettingsTab from './settings/SettingsTab';
+
+const APP_LINKS = [
+  { name: 'Hub', port: 7480, color: '#0891b2', icon: 'H' },
+  { name: 'SDCC', port: 7400, color: '#3b82f6', icon: 'S' },
+  { name: 'Grant', port: 7450, color: '#059669', icon: 'G' },
+  { name: 'Patty', port: 7460, color: '#7c3aed', icon: 'P' },
+  { name: 'Freddy', port: 7470, color: '#d97706', icon: 'F' },
+];
+const CURRENT_PORT = 7460;
+
+/* ─── Inner shell (needs auth context) ──────────────────────────────────── */
+function Shell() {
+  const { user, logout } = useAuth();
+  const [activeTab, setActiveTab] = useState<TabId>('dashboard');
+  const [sidebarOpen, setSidebarOpen] = useState(false);
+  const [showAppSwitcher, setShowAppSwitcher] = useState(false);
+  const [selectedPetitionId, setSelectedPetitionId] = useState<number | null>(null);
+  const [selectedGrantId, setSelectedGrantId] = useState<string | null>(null);
+
+  function handleTabChange(tab: TabId) {
+    setActiveTab(tab);
+    setSidebarOpen(false);
+  }
+
+  function handleViewPetition(id: number) {
+    setSelectedPetitionId(id);
+    setActiveTab('petitions');
+  }
+
+  function handleViewGrant(id: string) {
+    setSelectedGrantId(id);
+    setActiveTab('grants');
+  }
+
+  function renderPanel() {
+    switch (activeTab) {
+      case 'dashboard':   return <DashboardTab />;
+      case 'newsfeed':    return <NewsFeedTab />;
+      case 'topics':      return <TopicsTab onViewPetition={handleViewPetition} onViewGrant={handleViewGrant} />;
+      case 'petitions':   return <PetitionsTab selectedId={selectedPetitionId} onSelectedConsumed={() => setSelectedPetitionId(null)} onViewGrant={handleViewGrant} />;
+      case 'grants':      return <GrantsTab selectedId={selectedGrantId} onSelectedConsumed={() => setSelectedGrantId(null)} />;
+      case 'heatmap':     return <HeatMapTab />;
+      case 'campaigns':   return <CampaignsTab />;
+      case 'sources':     return <SourcesTab />;
+      case 'settings':    return <SettingsTab />;
+    }
+  }
+
+  const TAB_LABELS: Record<TabId, string> = {
+    dashboard:  'Dashboard',
+    newsfeed:   'News Feed',
+    topics:     'Topics',
+    petitions:  'Petitions',
+    grants:     'Grants',
+    heatmap:    'Heat Map',
+    campaigns:  'Campaigns',
+    sources:    'Sources',
+    settings:   'Settings',
+  };
+
+  return (
+    <div
+      className="flex"
+      style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
+    >
+      <Sidebar activeTab={activeTab} onTabChange={handleTabChange} isOpen={sidebarOpen} onToggle={() => setSidebarOpen(p => !p)} />
+
+      <div className="flex flex-col flex-1 min-w-0">
+        <header
+          className="flex items-center justify-between px-5 h-14 shrink-0"
+          style={{
+            backgroundColor: 'var(--color-surface)',
+            borderBottom: '1px solid var(--color-border)',
+          }}
+        >
+          <div className="flex items-center gap-2">
+            <button
+              className="btn btn-ghost btn-sm"
+              onClick={() => setSidebarOpen(p => !p)}
+              style={{ display: 'none' }}
+              aria-label="Toggle menu"
+              id="mobile-menu-btn"
+            >
+              <Menu size={18} />
+            </button>
+            <h1
+              className="text-sm font-semibold"
+              style={{ color: 'var(--color-text)' }}
+            >
+              {TAB_LABELS[activeTab]}
+            </h1>
+          </div>
+
+          <div className="flex items-center gap-3">
+            <div style={{ position: 'relative' }}>
+              <button
+                className="btn btn-ghost btn-sm"
+                onClick={() => setShowAppSwitcher(p => !p)}
+                title="Switch app"
+                aria-label="Switch app"
+              >
+                <LayoutGrid size={16} />
+              </button>
+              {showAppSwitcher && (
+                <>
+                  <div
+                    style={{ position: 'fixed', inset: 0, zIndex: 49 }}
+                    onClick={() => setShowAppSwitcher(false)}
+                  />
+                  <div
+                    style={{
+                      position: 'absolute',
+                      top: '100%',
+                      right: 0,
+                      marginTop: 8,
+                      padding: 8,
+                      borderRadius: 12,
+                      backgroundColor: 'var(--color-surface)',
+                      border: '1px solid var(--color-border)',
+                      boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
+                      zIndex: 50,
+                      minWidth: 200,
+                    }}
+                  >
+                    <div style={{ padding: '4px 8px 8px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+                      SDCC Ecosystem
+                    </div>
+                    {APP_LINKS.map((app) => (
+                      <a
+                        key={app.name}
+                        href={`http://45.61.58.125:${app.port}`}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        style={{
+                          display: 'flex',
+                          alignItems: 'center',
+                          gap: 10,
+                          padding: '8px 10px',
+                          borderRadius: 8,
+                          textDecoration: 'none',
+                          color: 'var(--color-text)',
+                          fontSize: '0.8125rem',
+                          fontWeight: 500,
+                          transition: 'background-color 0.15s',
+                          borderLeft: app.port === CURRENT_PORT ? `3px solid ${app.color}` : '3px solid transparent',
+                        }}
+                        onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+                        onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+                        onClick={() => setShowAppSwitcher(false)}
+                      >
+                        <div
+                          style={{
+                            width: 28,
+                            height: 28,
+                            borderRadius: 8,
+                            background: `linear-gradient(135deg, ${app.color}, ${app.color}dd)`,
+                            display: 'flex',
+                            alignItems: 'center',
+                            justifyContent: 'center',
+                            color: '#fff',
+                            fontSize: 12,
+                            fontWeight: 800,
+                            flexShrink: 0,
+                          }}
+                        >
+                          {app.icon}
+                        </div>
+                        <div>
+                          <div style={{ lineHeight: 1.2 }}>{app.name}</div>
+                          <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2 }}>
+                            Port {app.port}
+                          </div>
+                        </div>
+                      </a>
+                    ))}
+                  </div>
+                </>
+              )}
+            </div>
+            {user && (
+              <span
+                className="text-xs hidden sm:inline"
+                style={{ color: 'var(--color-text-muted)' }}
+              >
+                {user}
+              </span>
+            )}
+            <button
+              onClick={logout}
+              className="btn btn-ghost btn-sm"
+              aria-label="Sign out"
+              title="Sign out"
+            >
+              <LogOut size={15} aria-hidden="true" />
+              <span className="hidden sm:inline">Sign out</span>
+            </button>
+          </div>
+        </header>
+
+        <main className="flex-1 overflow-auto">
+          {renderPanel()}
+        </main>
+      </div>
+    </div>
+  );
+}
+
+export default function AppShell() {
+  return (
+    <AuthProvider>
+      <ToastProvider>
+        <Shell />
+      </ToastProvider>
+    </AuthProvider>
+  );
+}
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
new file mode 100644
index 0000000..afa0798
--- /dev/null
+++ b/components/AuthProvider.tsx
@@ -0,0 +1,132 @@
+'use client';
+
+import {
+  createContext,
+  useContext,
+  useEffect,
+  useState,
+  useCallback,
+  ReactNode,
+} from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import { Loader2 } from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface AuthContextValue {
+  user: string | null;
+  loading: boolean;
+  login: (username: string, password: string) => Promise<{ error?: string }>;
+  logout: () => Promise<void>;
+}
+
+/* ─── Context ────────────────────────────────────────────────────────────── */
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+/* ─── Provider ───────────────────────────────────────────────────────────── */
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const router   = useRouter();
+  const pathname = usePathname();
+
+  const [user, setUser]       = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  /* Check session on mount */
+  useEffect(() => {
+    let cancelled = false;
+
+    async function checkSession() {
+      try {
+        const res = await fetch('/api/auth/session', { credentials: 'include' });
+        if (!cancelled) {
+          if (res.ok) {
+            const data = await res.json();
+            setUser(data.authenticated ? data.user : null);
+          } else {
+            setUser(null);
+          }
+        }
+      } catch {
+        if (!cancelled) setUser(null);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+
+    checkSession();
+    return () => { cancelled = true; };
+  }, []);
+
+  /* Redirect unauthenticated users away from protected routes */
+  useEffect(() => {
+    if (loading) return;
+    if (!user && pathname !== '/login') {
+      router.replace('/login');
+    }
+  }, [user, loading, pathname, router]);
+
+  /* Login */
+  const login = useCallback(async (username: string, password: string) => {
+    const res = await fetch('/api/auth/login', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',
+      body: JSON.stringify({ username, password }),
+    });
+
+    const data = await res.json();
+
+    if (res.ok && data.success) {
+      setUser(username);
+      router.push('/');
+      router.refresh();
+      return {};
+    }
+
+    return { error: data.error ?? 'Login failed' };
+  }, [router]);
+
+  /* Logout */
+  const logout = useCallback(async () => {
+    await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
+    setUser(null);
+    router.replace('/login');
+  }, [router]);
+
+  /* Loading screen */
+  if (loading) {
+    return (
+      <div
+        className="min-h-screen flex flex-col items-center justify-center gap-3"
+        style={{ backgroundColor: 'var(--color-bg)' }}
+      >
+        <Loader2
+          size={28}
+          className="animate-spin"
+          style={{ color: 'var(--color-primary)' }}
+          aria-hidden="true"
+        />
+        <span
+          className="text-sm"
+          style={{ color: 'var(--color-text-secondary)' }}
+        >
+          Loading...
+        </span>
+      </div>
+    );
+  }
+
+  return (
+    <AuthContext.Provider value={{ user, loading, login, logout }}>
+      {children}
+    </AuthContext.Provider>
+  );
+}
+
+/* ─── Hook ───────────────────────────────────────────────────────────────── */
+export function useAuth(): AuthContextValue {
+  const ctx = useContext(AuthContext);
+  if (!ctx) {
+    throw new Error('useAuth must be used inside <AuthProvider>');
+  }
+  return ctx;
+}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
new file mode 100644
index 0000000..b9e3090
--- /dev/null
+++ b/components/Sidebar.tsx
@@ -0,0 +1,311 @@
+'use client';
+
+import { useState } from 'react';
+import {
+  LayoutDashboard,
+  Newspaper,
+  Lightbulb,
+  Megaphone,
+  DollarSign,
+  MapPin,
+  Mail,
+  Radio,
+  Settings,
+  ChevronLeft,
+  ChevronRight,
+} from 'lucide-react';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+export type TabId =
+  | 'dashboard'
+  | 'newsfeed'
+  | 'topics'
+  | 'petitions'
+  | 'grants'
+  | 'heatmap'
+  | 'campaigns'
+  | 'sources'
+  | 'settings';
+
+interface NavItem {
+  id: TabId;
+  label: string;
+  Icon: React.ElementType;
+  section: 'main' | 'tools';
+}
+
+interface SidebarProps {
+  activeTab: TabId;
+  onTabChange: (tab: TabId) => void;
+  isOpen?: boolean;
+  onToggle?: () => void;
+}
+
+/* ─── Navigation config ──────────────────────────────────────────────────── */
+const NAV_ITEMS: NavItem[] = [
+  { id: 'dashboard',  label: 'Dashboard',  Icon: LayoutDashboard, section: 'main' },
+  { id: 'newsfeed',   label: 'News Feed',  Icon: Newspaper,       section: 'main' },
+  { id: 'topics',     label: 'Topics',     Icon: Lightbulb,       section: 'main' },
+  { id: 'petitions',  label: 'Petitions',  Icon: Megaphone,       section: 'main' },
+  { id: 'grants',     label: 'Grants',     Icon: DollarSign,      section: 'main' },
+  { id: 'heatmap',    label: 'Heat Map',   Icon: MapPin,          section: 'main' },
+  { id: 'campaigns',  label: 'Campaigns',  Icon: Mail,            section: 'tools' },
+  { id: 'sources',    label: 'Sources',    Icon: Radio,           section: 'tools' },
+  { id: 'settings',   label: 'Settings',   Icon: Settings,        section: 'tools' },
+];
+
+/* ─── Sidebar ────────────────────────────────────────────────────────────── */
+export default function Sidebar({ activeTab, onTabChange, isOpen, onToggle }: SidebarProps) {
+  const [collapsed, setCollapsed] = useState(false);
+
+  const mainItems = NAV_ITEMS.filter((i) => i.section === 'main');
+  const toolItems = NAV_ITEMS.filter((i) => i.section === 'tools');
+
+  return (
+    <>
+    {isOpen && onToggle && (
+      <div
+        onClick={onToggle}
+        className="sidebar-backdrop"
+        style={{
+          position: 'fixed',
+          inset: 0,
+          backgroundColor: 'rgba(0,0,0,0.5)',
+          zIndex: 39,
+        }}
+      />
+    )}
+    <aside
+      className={`sidebar-root${isOpen ? ' sidebar-open' : ''}`}
+      style={{
+        width: collapsed ? 64 : 220,
+        minWidth: collapsed ? 64 : 220,
+        transition: 'width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
+        height: '100vh',
+        display: 'flex',
+        flexDirection: 'column',
+        backgroundColor: 'var(--color-surface)',
+        borderRight: '1px solid var(--color-border)',
+        position: 'relative',
+        overflow: 'hidden',
+        flexShrink: 0,
+      }}
+      aria-label="Main navigation"
+    >
+      {/* ── Brand ──────────────────────────────────────────────────────────── */}
+      <div
+        style={{
+          padding: collapsed ? '16px 0' : '16px',
+          display: 'flex',
+          alignItems: 'center',
+          gap: 10,
+          justifyContent: collapsed ? 'center' : 'flex-start',
+          borderBottom: '1px solid var(--color-border)',
+          height: 56,
+          flexShrink: 0,
+        }}
+      >
+        <div
+          style={{
+            width: 32,
+            height: 32,
+            borderRadius: 10,
+            background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            flexShrink: 0,
+            boxShadow: '0 2px 8px rgba(124, 58, 237, 0.3)',
+          }}
+        >
+          <span style={{ color: '#fff', fontSize: 14, fontWeight: 900, lineHeight: 1 }}>P</span>
+        </div>
+        {!collapsed && (
+          <div style={{ overflow: 'hidden', whiteSpace: 'nowrap' }}>
+            <div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>
+              Pulse
+            </div>
+            <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
+              News &amp; Advocacy
+            </div>
+          </div>
+        )}
+      </div>
+
+      {/* ── Main Nav ───────────────────────────────────────────────────────── */}
+      <nav style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 8px 0' }}>
+        {!collapsed && (
+          <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+            Workspace
+          </div>
+        )}
+        <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+          {mainItems.map(({ id, label, Icon }) => (
+            <SidebarButton
+              key={id}
+              id={id}
+              label={label}
+              Icon={Icon}
+              isActive={activeTab === id}
+              collapsed={collapsed}
+              onClick={() => onTabChange(id)}
+            />
+          ))}
+        </div>
+
+        {/* Divider */}
+        <div
+          style={{
+            height: 1,
+            backgroundColor: 'var(--color-border)',
+            margin: collapsed ? '12px 8px' : '12px 4px',
+            opacity: 0.6,
+          }}
+        />
+
+        {!collapsed && (
+          <div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
+            Tools
+          </div>
+        )}
+        <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
+          {toolItems.map(({ id, label, Icon }) => (
+            <SidebarButton
+              key={id}
+              id={id}
+              label={label}
+              Icon={Icon}
+              isActive={activeTab === id}
+              collapsed={collapsed}
+              onClick={() => onTabChange(id)}
+            />
+          ))}
+        </div>
+      </nav>
+
+      {/* ── Collapse toggle ────────────────────────────────────────────────── */}
+      <div
+        style={{
+          padding: '8px',
+          borderTop: '1px solid var(--color-border)',
+          flexShrink: 0,
+        }}
+      >
+        <button
+          onClick={() => setCollapsed((c) => !c)}
+          title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+          aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
+          style={{
+            width: '100%',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: collapsed ? 'center' : 'flex-start',
+            gap: 10,
+            padding: collapsed ? '8px 0' : '8px 12px',
+            borderRadius: 8,
+            border: 'none',
+            backgroundColor: 'transparent',
+            color: 'var(--color-text-muted)',
+            cursor: 'pointer',
+            fontSize: 12,
+            transition: 'background-color 0.15s, color 0.15s',
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+            e.currentTarget.style.color = 'var(--color-text-secondary)';
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.backgroundColor = 'transparent';
+            e.currentTarget.style.color = 'var(--color-text-muted)';
+          }}
+        >
+          {collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
+          {!collapsed && <span>Collapse</span>}
+        </button>
+      </div>
+    </aside>
+    </>
+  );
+}
+
+/* ─── SidebarButton ──────────────────────────────────────────────────────── */
+function SidebarButton({
+  id,
+  label,
+  Icon,
+  isActive,
+  collapsed,
+  onClick,
+}: {
+  id: string;
+  label: string;
+  Icon: React.ElementType;
+  isActive: boolean;
+  collapsed: boolean;
+  onClick: () => void;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      aria-current={isActive ? 'page' : undefined}
+      title={collapsed ? label : undefined}
+      style={{
+        display: 'flex',
+        alignItems: 'center',
+        gap: 10,
+        padding: collapsed ? '10px 0' : '8px 12px',
+        justifyContent: collapsed ? 'center' : 'flex-start',
+        borderRadius: 8,
+        border: 'none',
+        cursor: 'pointer',
+        position: 'relative',
+        backgroundColor: isActive ? 'rgba(124, 58, 237, 0.12)' : 'transparent',
+        color: isActive ? 'var(--color-text)' : 'var(--color-text-secondary)',
+        fontSize: 13,
+        fontWeight: isActive ? 600 : 500,
+        transition: 'background-color 0.15s, color 0.15s',
+        width: '100%',
+        overflow: 'hidden',
+        whiteSpace: 'nowrap',
+      }}
+      onMouseEnter={(e) => {
+        if (!isActive) {
+          e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
+          e.currentTarget.style.color = 'var(--color-text)';
+        }
+      }}
+      onMouseLeave={(e) => {
+        if (!isActive) {
+          e.currentTarget.style.backgroundColor = 'transparent';
+          e.currentTarget.style.color = 'var(--color-text-secondary)';
+        }
+      }}
+    >
+      {/* Active indicator bar */}
+      {isActive && (
+        <div
+          style={{
+            position: 'absolute',
+            left: 0,
+            top: '20%',
+            bottom: '20%',
+            width: 3,
+            borderRadius: '0 3px 3px 0',
+            background: 'linear-gradient(180deg, #a78bfa, #7c3aed)',
+          }}
+        />
+      )}
+      <Icon
+        size={18}
+        aria-hidden="true"
+        style={{
+          color: isActive ? '#a78bfa' : 'currentColor',
+          flexShrink: 0,
+          transition: 'color 0.15s',
+        }}
+      />
+      {!collapsed && <span>{label}</span>}
+      {collapsed && <span className="sr-only">{label}</span>}
+    </button>
+  );
+}
diff --git a/components/Skeleton.tsx b/components/Skeleton.tsx
new file mode 100644
index 0000000..9b26db6
--- /dev/null
+++ b/components/Skeleton.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+export function SkeletonCard() {
+  return (
+    <div className="card" style={{ padding: '1rem' }}>
+      <div style={{ display: 'flex', alignItems: 'start', gap: 12 }}>
+        <div className="skeleton" style={{ width: 40, height: 40, borderRadius: 10, flexShrink: 0 }} />
+        <div style={{ flex: 1 }}>
+          <div className="skeleton" style={{ width: '60%', height: 14, borderRadius: 4, marginBottom: 8 }} />
+          <div className="skeleton" style={{ width: '40%', height: 10, borderRadius: 4, marginBottom: 12 }} />
+          <div className="skeleton" style={{ width: '90%', height: 10, borderRadius: 4, marginBottom: 6 }} />
+          <div className="skeleton" style={{ width: '75%', height: 10, borderRadius: 4 }} />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function SkeletonStatCard() {
+  return (
+    <div className="stat-card" style={{ padding: '1.25rem' }}>
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
+        <div className="skeleton" style={{ width: 36, height: 36, borderRadius: 8 }} />
+      </div>
+      <div className="skeleton" style={{ width: '50%', height: 24, borderRadius: 4, marginBottom: 6 }} />
+      <div className="skeleton" style={{ width: '70%', height: 10, borderRadius: 4 }} />
+    </div>
+  );
+}
+
+export function SkeletonList({ count = 4 }: { count?: number }) {
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+      {Array.from({ length: count }).map((_, i) => (
+        <SkeletonCard key={i} />
+      ))}
+    </div>
+  );
+}
+
+export function SkeletonStats({ count = 4 }: { count?: number }) {
+  return (
+    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 16 }}>
+      {Array.from({ length: count }).map((_, i) => (
+        <SkeletonStatCard key={i} />
+      ))}
+    </div>
+  );
+}
diff --git a/components/ToastProvider.tsx b/components/ToastProvider.tsx
new file mode 100644
index 0000000..612b1a2
--- /dev/null
+++ b/components/ToastProvider.tsx
@@ -0,0 +1,94 @@
+'use client';
+import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react';
+
+type ToastType = 'success' | 'error' | 'info' | 'warning';
+
+interface Toast {
+  id: number;
+  message: string;
+  type: ToastType;
+}
+
+interface ToastContextType {
+  addToast: (message: string, type?: ToastType) => void;
+}
+
+const ToastContext = createContext<ToastContextType>({ addToast: () => {} });
+
+export function useToast() {
+  return useContext(ToastContext);
+}
+
+const TYPE_STYLES: Record<ToastType, { bg: string; border: string; text: string }> = {
+  success: { bg: 'rgba(34,197,94,0.15)', border: 'rgba(34,197,94,0.4)', text: '#4ade80' },
+  error:   { bg: 'rgba(239,68,68,0.15)', border: 'rgba(239,68,68,0.4)', text: '#f87171' },
+  info:    { bg: 'rgba(59,130,246,0.15)', border: 'rgba(59,130,246,0.4)', text: '#60a5fa' },
+  warning: { bg: 'rgba(245,158,11,0.15)', border: 'rgba(245,158,11,0.4)', text: '#fbbf24' },
+};
+
+const ICONS: Record<ToastType, string> = {
+  success: '\u2713',
+  error: '\u2715',
+  warning: '\u26A0',
+  info: '\u2139',
+};
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+  const [toasts, setToasts] = useState<Toast[]>([]);
+  const nextId = useRef(0);
+
+  const addToast = useCallback((message: string, type: ToastType = 'success') => {
+    const id = ++nextId.current;
+    setToasts((prev) => [...prev, { id, message, type }]);
+    setTimeout(() => {
+      setToasts((prev) => prev.filter((t) => t.id !== id));
+    }, 3500);
+  }, []);
+
+  return (
+    <ToastContext.Provider value={{ addToast }}>
+      {children}
+      <div style={{
+        position: 'fixed',
+        bottom: 16,
+        right: 16,
+        zIndex: 9999,
+        display: 'flex',
+        flexDirection: 'column',
+        gap: 8,
+        pointerEvents: 'none',
+        maxWidth: 380,
+      }}>
+        {toasts.map((toast) => {
+          const style = TYPE_STYLES[toast.type];
+          return (
+            <div
+              key={toast.id}
+              style={{
+                padding: '10px 16px',
+                borderRadius: 10,
+                backgroundColor: style.bg,
+                border: `1px solid ${style.border}`,
+                color: style.text,
+                fontSize: '0.8125rem',
+                fontWeight: 500,
+                backdropFilter: 'blur(12px)',
+                boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+                animation: 'toast-in 0.25s ease-out',
+                pointerEvents: 'auto',
+                display: 'flex',
+                alignItems: 'center',
+                gap: 8,
+              }}
+            >
+              <span style={{ fontSize: '1rem', lineHeight: 1 }}>
+                {ICONS[toast.type]}
+              </span>
+              {toast.message}
+            </div>
+          );
+        })}
+      </div>
+    </ToastContext.Provider>
+  );
+}
diff --git a/components/campaigns/CampaignsTab.tsx b/components/campaigns/CampaignsTab.tsx
new file mode 100644
index 0000000..6429f55
--- /dev/null
+++ b/components/campaigns/CampaignsTab.tsx
@@ -0,0 +1,482 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+  Mail, Plus, Send, Clock, CheckCircle, Loader2,
+  X, Sparkles, Eye, MousePointer, AlertCircle,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const CAMP_SORT_OPTIONS = [
+  { value: 'date_desc', label: 'Newest First' },
+  { value: 'date_asc', label: 'Oldest First' },
+  { value: 'status', label: 'Status A-Z' },
+  { value: 'opens_desc', label: 'Most Opens' },
+];
+
+const CAMP_SORT_CONFIGS: Record<string, SortConfig> = {
+  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+  status: { key: 'status', direction: 'asc', type: 'string' },
+  opens_desc: { key: 'open_count', direction: 'desc', type: 'number' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Campaign {
+  id: string;
+  subject: string;
+  body_html: string;
+  body_text: string | null;
+  status: string;
+  petition_id: string | null;
+  petition_title: string | null;
+  send_to: string;
+  recipient_count: number;
+  open_count: number;
+  click_count: number;
+  scheduled_at: string | null;
+  sent_at: string | null;
+  created_at: string;
+}
+
+interface PetitionOption {
+  id: string;
+  title: string;
+}
+
+function stripHtml(html: string): string {
+  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function CampaignsTab() {
+  const { addToast } = useToast();
+  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [statusFilter, setStatusFilter] = useState('all');
+  const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+  const [sortBy, setSortBy] = useState('date_desc');
+  const sortedCampaigns = useClientSort(campaigns, sortBy, CAMP_SORT_CONFIGS);
+
+  // Create modal state
+  const [showCreate, setShowCreate] = useState(false);
+  const [petitions, setPetitions] = useState<PetitionOption[]>([]);
+  const [createPetitionId, setCreatePetitionId] = useState('');
+  const [createSubject, setCreateSubject] = useState('');
+  const [createBody, setCreateBody] = useState('');
+  const [createSchedule, setCreateSchedule] = useState('');
+  const [createSaving, setCreateSaving] = useState(false);
+  const [aiGenerating, setAiGenerating] = useState(false);
+
+  // Preview modal
+  const [previewCampaign, setPreviewCampaign] = useState<Campaign | null>(null);
+
+  /* ── Fetch Campaigns ────────────────────────────────────────── */
+  const fetchCampaigns = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      if (statusFilter !== 'all') params.set('status', statusFilter);
+      const res = await fetch(`/api/campaigns?${params.toString()}`);
+      if (res.ok) {
+        const data = await res.json();
+        setCampaigns(data.rows || []);
+        setStatusCounts(data.statusCounts || {});
+      }
+    } catch (err) {
+      console.error('Fetch campaigns error:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [statusFilter]);
+
+  useEffect(() => { fetchCampaigns(); }, [fetchCampaigns]);
+
+  /* ── Escape key handler ───────────────────────────────────────── */
+  useEffect(() => {
+    if (!showCreate && !previewCampaign) return;
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (showCreate) setShowCreate(false);
+        if (previewCampaign) setPreviewCampaign(null);
+      }
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [showCreate, previewCampaign]);
+
+  /* ── Fetch Petitions for Dropdown ───────────────────────────── */
+  async function fetchPetitions() {
+    try {
+      const res = await fetch('/api/petitions');
+      if (res.ok) {
+        const data = await res.json();
+        setPetitions((data.rows || []).map((r: { id: string; title: string }) => ({ id: r.id, title: r.title })));
+      }
+    } catch (err) {
+      console.error('Fetch petitions error:', err);
+    }
+  }
+
+  function openCreate() {
+    setShowCreate(true);
+    setCreatePetitionId('');
+    setCreateSubject('');
+    setCreateBody('');
+    setCreateSchedule('');
+    fetchPetitions();
+  }
+
+  /* ── AI Generate ────────────────────────────────────────────── */
+  async function handleAiGenerate() {
+    setAiGenerating(true);
+    try {
+      const res = await fetch('/api/campaigns/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          petition_id: createPetitionId || null,
+          tone: 'urgent',
+          call_to_action_type: 'sign',
+        }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setCreateSubject(data.subject || '');
+        setCreateBody(data.body_html || data.body_text || '');
+        addToast('Content generated', 'success');
+      } else {
+        addToast('AI generation failed', 'error');
+      }
+    } catch (err) {
+      console.error('AI generate error:', err);
+      addToast('AI generation failed', 'error');
+    } finally {
+      setAiGenerating(false);
+    }
+  }
+
+  /* ── Create Campaign ────────────────────────────────────────── */
+  async function handleCreate() {
+    if (!createSubject.trim() || !createBody.trim()) return;
+    setCreateSaving(true);
+    try {
+      const res = await fetch('/api/campaigns', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          subject: createSubject,
+          body_html: createBody,
+          petition_id: createPetitionId || null,
+          scheduled_at: createSchedule || null,
+        }),
+      });
+      if (res.ok) {
+        setShowCreate(false);
+        fetchCampaigns();
+        addToast('Campaign created', 'success');
+      } else {
+        addToast('Failed to create campaign', 'error');
+      }
+    } catch (err) {
+      console.error('Create campaign error:', err);
+      addToast('Failed to create campaign', 'error');
+    } finally {
+      setCreateSaving(false);
+    }
+  }
+
+  /* ── Send Now ───────────────────────────────────────────────── */
+  async function handleSendNow(id: string) {
+    try {
+      const res = await fetch('/api/campaigns', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ id, status: 'sending', sent_at: new Date().toISOString() }),
+      });
+      if (res.ok) {
+        fetchCampaigns();
+        addToast('Campaign sending', 'success');
+      } else {
+        addToast('Send failed', 'error');
+      }
+    } catch (err) {
+      console.error('Send now error:', err);
+      addToast('Send failed', 'error');
+    }
+  }
+
+  /* ── Helpers ────────────────────────────────────────────────── */
+  const statusFilters = ['all', 'draft', 'scheduled', 'sending', 'sent', 'failed'];
+
+  const statusBadge: Record<string, { cls: string; icon: React.ElementType }> = {
+    draft: { cls: 'badge-draft', icon: Mail },
+    scheduled: { cls: 'badge-paused', icon: Clock },
+    sending: { cls: 'badge-warning', icon: Send },
+    sent: { cls: 'badge-active', icon: CheckCircle },
+    failed: { cls: 'badge-closed', icon: AlertCircle },
+  };
+
+  const formatDate = (d: string | null) => {
+    if (!d) return '--';
+    return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+  };
+
+  const openRate = (c: Campaign) => {
+    if (c.recipient_count === 0) return '0%';
+    return `${Math.round((c.open_count / c.recipient_count) * 100)}%`;
+  };
+
+  const clickRate = (c: Campaign) => {
+    if (c.recipient_count === 0) return '0%';
+    return `${Math.round((c.click_count / c.recipient_count) * 100)}%`;
+  };
+
+  /* ── Stats ──────────────────────────────────────────────────── */
+  const totalCampaigns = statusCounts.all || 0;
+  const sentCount = statusCounts.sent || 0;
+  const scheduledCount = statusCounts.scheduled || 0;
+  const avgOpen = campaigns.length > 0
+    ? Math.round(campaigns.reduce((sum, c) => sum + (c.recipient_count > 0 ? (c.open_count / c.recipient_count) * 100 : 0), 0) / campaigns.length)
+    : 0;
+
+  /* ─── Render ────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Email Campaigns</h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            Send email blasts to your petition signers and subscribers
+          </p>
+        </div>
+        <button className="btn btn-primary" onClick={openCreate}>
+          <Plus size={16} />
+          Create Campaign
+        </button>
+      </div>
+
+      {/* Stats Row */}
+      <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
+        {[
+          { label: 'Total Campaigns', value: String(totalCampaigns), icon: Mail, color: 'var(--color-primary)' },
+          { label: 'Sent', value: String(sentCount), icon: Send, color: 'var(--color-success)' },
+          { label: 'Scheduled', value: String(scheduledCount), icon: Clock, color: 'var(--color-warning)' },
+          { label: 'Avg Open Rate', value: `${avgOpen}%`, icon: CheckCircle, color: 'var(--color-secondary)' },
+        ].map((stat, i) => (
+          <div key={i} className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
+            <div
+              className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
+              style={{ backgroundColor: `${stat.color}15`, border: `1px solid ${stat.color}30` }}
+            >
+              <stat.icon size={18} style={{ color: stat.color }} />
+            </div>
+            <div>
+              <div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</div>
+              <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</div>
+            </div>
+          </div>
+        ))}
+      </div>
+
+      {/* Sort + Status Filters */}
+      <div className="flex items-center gap-1 mb-6 flex-wrap">
+        <SortDropdown options={CAMP_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+        {statusFilters.map((s) => (
+          <button
+            key={s}
+            onClick={() => setStatusFilter(s)}
+            className={`btn btn-sm ${statusFilter === s ? 'btn-primary' : 'btn-ghost'}`}
+            style={{ textTransform: 'capitalize' }}
+          >
+            {s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
+          </button>
+        ))}
+      </div>
+
+      {/* Loading */}
+      {loading && (
+        <SkeletonList count={4} />
+      )}
+
+      {/* Empty State */}
+      {!loading && campaigns.length === 0 && (
+        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+          <div
+            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+            style={{
+              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+              border: '1px solid rgba(124, 58, 237, 0.2)',
+            }}
+          >
+            <Mail size={28} style={{ color: 'var(--color-primary)' }} />
+          </div>
+          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No campaigns yet</h3>
+          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+            Create email campaigns to update petition signers and rally supporters.
+          </p>
+          <button className="btn btn-primary" onClick={openCreate}>
+            <Plus size={16} />
+            Create First Campaign
+          </button>
+        </div>
+      )}
+
+      {/* Campaign Cards */}
+      {!loading && sortedCampaigns.length > 0 && (
+        <div className="flex flex-col gap-3">
+          {sortedCampaigns.map((c) => {
+            const badge = statusBadge[c.status] || statusBadge.draft;
+            return (
+              <div key={c.id} className="card">
+                <div className="flex items-start justify-between gap-4">
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2 mb-1 flex-wrap">
+                      <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+                        {c.subject}
+                      </h3>
+                      <span className={`badge ${badge.cls}`}>{c.status}</span>
+                    </div>
+                    {c.petition_title && (
+                      <p className="text-xs mb-2" style={{ color: 'var(--color-secondary)' }}>
+                        Linked: {c.petition_title}
+                      </p>
+                    )}
+                    <div className="flex items-center gap-4 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                      <span className="flex items-center gap-1">
+                        <Mail size={12} /> {c.recipient_count} recipients
+                      </span>
+                      <span className="flex items-center gap-1">
+                        <Eye size={12} /> {openRate(c)} opens
+                      </span>
+                      <span className="flex items-center gap-1">
+                        <MousePointer size={12} /> {clickRate(c)} clicks
+                      </span>
+                      <span>
+                        {c.sent_at ? `Sent ${formatDate(c.sent_at)}` : c.scheduled_at ? `Scheduled ${formatDate(c.scheduled_at)}` : `Created ${formatDate(c.created_at)}`}
+                      </span>
+                    </div>
+                  </div>
+                  <div className="flex items-center gap-1 shrink-0">
+                    <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(c)} title="Preview">
+                      <Eye size={14} />
+                    </button>
+                    {c.status === 'draft' && (
+                      <button className="btn btn-sm btn-primary" onClick={() => handleSendNow(c.id)}>
+                        <Send size={14} /> Send Now
+                      </button>
+                    )}
+                  </div>
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      )}
+
+      {/* ─── Create Campaign Modal ────────────────────────────────── */}
+      {showCreate && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
+        >
+          <div style={{
+            width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+            borderRadius: 12, padding: 24,
+          }}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Campaign</h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
+            </div>
+            <div className="flex flex-col gap-4">
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Linked Petition (optional)</label>
+                <select className="input" value={createPetitionId} onChange={(e) => setCreatePetitionId(e.target.value)}>
+                  <option value="">-- None --</option>
+                  {petitions.map((p) => (
+                    <option key={p.id} value={p.id}>{p.title}</option>
+                  ))}
+                </select>
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Subject *</label>
+                <input className="input" value={createSubject} onChange={(e) => setCreateSubject(e.target.value)} placeholder="Email subject line" />
+              </div>
+              <div>
+                <div className="flex items-center justify-between mb-1">
+                  <label className="block text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
+                  <button className="btn btn-ghost btn-sm" onClick={handleAiGenerate} disabled={aiGenerating}>
+                    {aiGenerating ? <><Loader2 size={12} className="animate-spin" /> Generating...</> : <><Sparkles size={12} /> AI Generate</>}
+                  </button>
+                </div>
+                <textarea
+                  className="input"
+                  rows={8}
+                  value={createBody}
+                  onChange={(e) => setCreateBody(e.target.value)}
+                  placeholder="Email body content (HTML supported)"
+                  style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
+                />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Schedule (optional)</label>
+                <input className="input" type="datetime-local" value={createSchedule} onChange={(e) => setCreateSchedule(e.target.value)} />
+              </div>
+              <button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createSubject.trim() || !createBody.trim()}>
+                {createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Campaign</>}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* ─── Preview Modal ────────────────────────────────────────── */}
+      {previewCampaign && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setPreviewCampaign(null); }}
+        >
+          <div style={{
+            width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+            borderRadius: 12, padding: 24,
+          }}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Preview</h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(null)}><X size={16} /></button>
+            </div>
+            <div className="mb-3">
+              <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>Subject</div>
+              <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{previewCampaign.subject}</div>
+            </div>
+            <div
+              className="text-sm"
+              style={{
+                color: 'var(--color-text-secondary)',
+                padding: 12, borderRadius: 8,
+                backgroundColor: 'var(--color-surface-el)',
+                whiteSpace: 'pre-wrap',
+                maxHeight: 400, overflow: 'auto',
+              }}
+            >
+              {stripHtml(previewCampaign.body_html)}
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/compliance/ComplianceTab.tsx b/components/compliance/ComplianceTab.tsx
new file mode 100644
index 0000000..31c4457
--- /dev/null
+++ b/components/compliance/ComplianceTab.tsx
@@ -0,0 +1,213 @@
+'use client';
+
+import { Shield, AlertTriangle, CheckCircle, Info, ExternalLink } from 'lucide-react';
+
+export default function ComplianceTab() {
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+            Legal Compliance
+          </h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            Understand the legal landscape for petition and advocacy activities
+          </p>
+        </div>
+      </div>
+
+      {/* Compliance Status */}
+      <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
+        {[
+          {
+            label: 'Organization Type',
+            value: 'Not Set',
+            icon: Shield,
+            color: 'var(--color-warning)',
+            desc: 'Set your org type for compliance guidance',
+          },
+          {
+            label: 'Email Compliance',
+            value: 'CAN-SPAM Ready',
+            icon: CheckCircle,
+            color: 'var(--color-success)',
+            desc: 'Unsubscribe links are auto-included',
+          },
+          {
+            label: 'Data Privacy',
+            value: 'GDPR Aware',
+            icon: Shield,
+            color: 'var(--color-success)',
+            desc: 'Consent tracking is enabled',
+          },
+        ].map((item, i) => (
+          <div key={i} className="card" style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
+            <div
+              className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
+              style={{
+                backgroundColor: `${item.color}15`,
+                border: `1px solid ${item.color}30`,
+              }}
+            >
+              <item.icon size={18} style={{ color: item.color }} />
+            </div>
+            <div>
+              <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                {item.label}
+              </div>
+              <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+                {item.value}
+              </div>
+              <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
+                {item.desc}
+              </div>
+            </div>
+          </div>
+        ))}
+      </div>
+
+      {/* Organization Types Guide */}
+      <h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
+        Organization Type Guide
+      </h3>
+      <div className="flex flex-col gap-4 mb-8">
+        {[
+          {
+            type: '501(c)(3) -- Charitable Organization',
+            rules: [
+              'Can conduct non-partisan voter education and registration',
+              'Cannot engage in partisan political activities',
+              'Limited lobbying (up to 20% of budget under the expenditure test)',
+              'Petition activities must relate to educational or charitable purpose',
+              'Cannot endorse or oppose political candidates',
+            ],
+            color: '#22c55e',
+          },
+          {
+            type: '501(c)(4) -- Social Welfare Organization',
+            rules: [
+              'Can engage in unlimited lobbying related to social welfare mission',
+              'Can participate in some political activities (not primary activity)',
+              'Petition activities are a core part of the social welfare mission',
+              'Must report lobbying and political expenditures',
+              'Donations are not tax-deductible for donors',
+            ],
+            color: '#3b82f6',
+          },
+          {
+            type: 'Individual / For-Profit',
+            rules: [
+              'No tax restrictions on petition or advocacy activities',
+              'Must comply with campaign finance laws if petition is election-related',
+              'State-specific lobbying registration may apply',
+              'CAN-SPAM compliance required for email communications',
+              'Privacy laws (GDPR, CCPA) apply to signer data collection',
+            ],
+            color: '#a78bfa',
+          },
+        ].map((org, i) => (
+          <div key={i} className="card">
+            <div className="flex items-center gap-2 mb-3">
+              <div
+                className="w-2 h-2 rounded-full"
+                style={{ backgroundColor: org.color }}
+              />
+              <h4 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+                {org.type}
+              </h4>
+            </div>
+            <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
+              {org.rules.map((rule, j) => (
+                <li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+                  <CheckCircle size={14} style={{ color: org.color, flexShrink: 0, marginTop: 1 }} />
+                  {rule}
+                </li>
+              ))}
+            </ul>
+          </div>
+        ))}
+      </div>
+
+      {/* Key Compliance Areas */}
+      <h3 className="text-sm font-semibold mb-4" style={{ color: 'var(--color-text)' }}>
+        Key Compliance Areas
+      </h3>
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        {[
+          {
+            title: 'Email Compliance (CAN-SPAM)',
+            items: [
+              'Include physical mailing address',
+              'Provide clear unsubscribe mechanism',
+              'Honor opt-out requests within 10 business days',
+              'Use accurate sender information',
+            ],
+          },
+          {
+            title: 'Data Privacy (GDPR / CCPA)',
+            items: [
+              'Obtain explicit consent before collecting data',
+              'Provide data deletion mechanism',
+              'Document data processing purposes',
+              'Implement reasonable security measures',
+            ],
+          },
+          {
+            title: 'Petition-Specific Rules',
+            items: [
+              'Verify signer identity where required by state law',
+              'Maintain signature records for audit',
+              'Disclose petition sponsor/funder',
+              'Follow state rules for ballot initiative petitions',
+            ],
+          },
+          {
+            title: 'Accessibility (ADA / WCAG)',
+            items: [
+              'Petition pages must be screen-reader accessible',
+              'Provide alt text for all images',
+              'Ensure keyboard navigation works',
+              'Maintain sufficient color contrast',
+            ],
+          },
+        ].map((area, i) => (
+          <div key={i} className="card">
+            <h4 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
+              {area.title}
+            </h4>
+            <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
+              {area.items.map((item, j) => (
+                <li key={j} className="flex items-start gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+                  <Info size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0, marginTop: 2 }} />
+                  {item}
+                </li>
+              ))}
+            </ul>
+          </div>
+        ))}
+      </div>
+
+      {/* Disclaimer */}
+      <div
+        className="mt-8 rounded-lg p-4 flex items-start gap-3"
+        style={{
+          backgroundColor: 'rgba(245, 158, 11, 0.08)',
+          border: '1px solid rgba(245, 158, 11, 0.2)',
+        }}
+      >
+        <AlertTriangle size={18} style={{ color: 'var(--color-warning)', flexShrink: 0, marginTop: 2 }} />
+        <div>
+          <div className="text-sm font-semibold mb-1" style={{ color: 'var(--color-warning)' }}>
+            Disclaimer
+          </div>
+          <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+            This information is provided for general guidance only and does not constitute legal advice.
+            Consult with a qualified attorney for legal guidance specific to your organization
+            and jurisdiction. Laws vary by state and locality.
+          </p>
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/components/dashboard/DashboardTab.tsx b/components/dashboard/DashboardTab.tsx
new file mode 100644
index 0000000..a81bada
--- /dev/null
+++ b/components/dashboard/DashboardTab.tsx
@@ -0,0 +1,175 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+  Radio, Newspaper, BarChart3, Activity,
+  Megaphone, FileText, RefreshCw, Loader2,
+  DollarSign, TrendingUp,
+} from 'lucide-react';
+
+interface PulseStats {
+  total_feeds: number;
+  active_feeds: number;
+  total_articles: number;
+  articles_today: number;
+  avg_sentiment: number;
+  top_topics: string[];
+  last_fetch: string;
+}
+
+interface PetitionCounts {
+  draft: number;
+  active: number;
+  all: number;
+}
+
+function StatCard({ icon: Icon, label, value, sub, color }: {
+  icon: React.ElementType; label: string; value: string | number; sub?: string; color: string;
+}) {
+  return (
+    <div style={{
+      padding: 20, borderRadius: 12,
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      display: 'flex', alignItems: 'flex-start', gap: 14,
+    }}>
+      <div style={{
+        width: 40, height: 40, borderRadius: 10, backgroundColor: color + '18',
+        display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
+      }}>
+        <Icon size={20} style={{ color }} />
+      </div>
+      <div>
+        <div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</div>
+        <div style={{ fontSize: 28, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2 }}>{value}</div>
+        {sub && <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>{sub}</div>}
+      </div>
+    </div>
+  );
+}
+
+export default function DashboardTab() {
+  const [stats, setStats] = useState<PulseStats | null>(null);
+  const [petitions, setPetitions] = useState<PetitionCounts | null>(null);
+  const [grantStats, setGrantStats] = useState<{ grant_count: number; total_funding: number } | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [fetching, setFetching] = useState(false);
+
+  async function loadData() {
+    setLoading(true);
+    try {
+      const [pulseRes, petRes, grantRes] = await Promise.all([
+        fetch('/api/pulse/stats'),
+        fetch('/api/petitions'),
+        fetch('/api/grants-proxy/dashboard').catch(() => null),
+      ]);
+      if (pulseRes.ok) setStats(await pulseRes.json());
+      if (petRes.ok) {
+        const d = await petRes.json();
+        setPetitions(d.statusCounts || { draft: 0, active: 0, all: 0 });
+      }
+      if (grantRes?.ok) {
+        const g = await grantRes.json();
+        setGrantStats({ grant_count: g.grant_count ?? 0, total_funding: g.total_funding ?? 0 });
+      }
+    } catch (err) { console.error('Dashboard load error:', err); }
+    finally { setLoading(false); }
+  }
+
+  useEffect(() => { loadData(); }, []);
+
+  async function triggerFetch() {
+    setFetching(true);
+    try {
+      await fetch('/api/pulse/fetch', { method: 'POST' });
+      await loadData();
+    } catch {}
+    finally { setFetching(false); }
+  }
+
+  function sentimentLabel(s: number) {
+    if (s > 0.1) return { text: 'Positive', color: '#22c55e' };
+    if (s < -0.1) return { text: 'Negative', color: '#ef4444' };
+    return { text: 'Neutral', color: '#a78bfa' };
+  }
+
+  if (loading) {
+    return (
+      <div style={{ padding: 32, color: 'var(--color-text-muted)' }}>
+        <Loader2 size={20} style={{ animation: 'spin 1s linear infinite' }} /> Loading dashboard...
+      </div>
+    );
+  }
+
+  const sent = stats ? sentimentLabel(stats.avg_sentiment) : { text: '-', color: '#888' };
+
+  return (
+    <div style={{ padding: 24, maxWidth: 1200 }}>
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
+        <div>
+          <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>Dashboard</h2>
+          {stats?.last_fetch && (
+            <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
+              Last fetch: {new Date(stats.last_fetch).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true })} PT
+            </div>
+          )}
+        </div>
+        <button
+          onClick={triggerFetch}
+          disabled={fetching}
+          style={{
+            display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
+            borderRadius: 8, border: '1px solid var(--color-border)',
+            backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+            cursor: fetching ? 'wait' : 'pointer', fontSize: 13, fontWeight: 500,
+          }}
+        >
+          {fetching ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={14} />}
+          {fetching ? 'Fetching...' : 'Fetch News'}
+        </button>
+      </div>
+
+      {/* Stat Cards */}
+      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: 16, marginBottom: 28 }}>
+        <StatCard icon={Radio} label="Active Feeds" value={stats?.active_feeds ?? '-'} sub={`${stats?.total_feeds ?? 0} total`} color="#3b82f6" />
+        <StatCard icon={Newspaper} label="Articles Today" value={stats?.articles_today?.toLocaleString() ?? '-'} sub={`${stats?.total_articles?.toLocaleString() ?? 0} total`} color="#8b5cf6" />
+        <StatCard icon={Activity} label="Avg Sentiment" value={sent.text} sub={stats ? stats.avg_sentiment.toFixed(3) : '-'} color={sent.color} />
+        <StatCard icon={Megaphone} label="Active Petitions" value={petitions?.active ?? '-'} sub={`${petitions?.all ?? 0} total`} color="#f59e0b" />
+        <StatCard icon={FileText} label="Draft Petitions" value={petitions?.draft ?? '-'} color="#6b7280" />
+        <StatCard icon={BarChart3} label="Topics Generated" value={stats?.top_topics?.length ?? '-'} color="#10b981" />
+        <StatCard icon={DollarSign} label="Grants Found" value={grantStats?.grant_count ?? '-'} color="#059669" />
+        <StatCard icon={TrendingUp} label="Total Funding" value={grantStats?.total_funding ? `$${grantStats.total_funding.toLocaleString()}` : '$0'} color="#059669" />
+      </div>
+
+      {/* Top Topics */}
+      {stats?.top_topics && stats.top_topics.length > 0 && (
+        <div style={{
+          padding: 20, borderRadius: 12,
+          backgroundColor: 'var(--color-surface)',
+          border: '1px solid var(--color-border)',
+        }}>
+          <h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)', marginBottom: 12 }}>
+            Top AI Topics
+          </h3>
+          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+            {stats.top_topics.map((t, i) => (
+              <div key={i} style={{
+                display: 'flex', alignItems: 'center', gap: 10,
+                padding: '8px 12px', borderRadius: 8,
+                backgroundColor: 'var(--color-bg)',
+              }}>
+                <span style={{
+                  width: 22, height: 22, borderRadius: 6,
+                  backgroundColor: '#7c3aed22', color: '#a78bfa',
+                  display: 'flex', alignItems: 'center', justifyContent: 'center',
+                  fontSize: 11, fontWeight: 700, flexShrink: 0,
+                }}>{i + 1}</span>
+                <span style={{ fontSize: 13, color: 'var(--color-text)' }}>{t}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/grants/GrantsTab.tsx b/components/grants/GrantsTab.tsx
new file mode 100644
index 0000000..7004487
--- /dev/null
+++ b/components/grants/GrantsTab.tsx
@@ -0,0 +1,549 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import {
+  DollarSign, Search, Sparkles, Loader2, ChevronDown, ChevronUp,
+  Bookmark, BookmarkCheck, ExternalLink, Calendar, Tag, Target,
+  AlertCircle, Clock, TrendingUp,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import { SkeletonList } from '../Skeleton';
+import { Grant, formatAmount, STATUS_COLORS, STATUS_LABELS } from './types';
+
+/* ─── Constants ──────────────────────────────────────────────────────────── */
+const PRIORITY_COLORS: Record<string, string> = {
+  high: '#ef4444',
+  medium: '#f59e0b',
+  low: '#6b7280',
+};
+
+/* ─── Component ──────────────────────────────────────────────────────────── */
+export default function GrantsTab({
+  selectedId,
+  onSelectedConsumed,
+}: {
+  selectedId?: string | null;
+  onSelectedConsumed?: () => void;
+}) {
+  const { addToast } = useToast();
+  const [grants, setGrants] = useState<Grant[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+  const [filter, setFilter] = useState('all');
+  const [search, setSearch] = useState('');
+  const debouncedSearch = useDebounce(search, 300);
+  const [expanded, setExpanded] = useState<string | null>(null);
+  const [discovering, setDiscovering] = useState(false);
+
+  /* ── Quick stats ─────────────────────────────────────────────────── */
+  const [dashStats, setDashStats] = useState<{
+    grant_count: number;
+    total_funding: number;
+  } | null>(null);
+
+  /* ── Fetch grants ────────────────────────────────────────────────── */
+  const fetchGrants = useCallback(async () => {
+    try {
+      const params = new URLSearchParams();
+      if (filter !== 'all') params.set('status', filter);
+      if (debouncedSearch) params.set('search', debouncedSearch);
+      const qs = params.toString() ? `?${params}` : '';
+
+      const res = await fetch(`/api/grants-proxy/grants${qs}`);
+      if (!res.ok) throw new Error('Failed to load grants');
+      const data = await res.json();
+      setGrants(data.rows || []);
+      setStatusCounts(data.statusCounts || {});
+    } catch (err) {
+      console.error('Grants fetch error:', err);
+      addToast('Failed to load grants', 'error');
+    } finally {
+      setLoading(false);
+    }
+  }, [filter, debouncedSearch, addToast]);
+
+  const fetchDashStats = useCallback(async () => {
+    try {
+      const res = await fetch('/api/grants-proxy/dashboard');
+      if (res.ok) {
+        const data = await res.json();
+        setDashStats({ grant_count: data.grant_count, total_funding: data.total_funding });
+      }
+    } catch { /* ignore */ }
+  }, []);
+
+  useEffect(() => {
+    fetchGrants();
+    fetchDashStats();
+  }, [fetchGrants, fetchDashStats]);
+
+  /* ── Cross-tab navigation ────────────────────────────────────────── */
+  useEffect(() => {
+    if (selectedId) {
+      setExpanded(selectedId);
+      onSelectedConsumed?.();
+      // scroll into view after render
+      setTimeout(() => {
+        document.getElementById(`grant-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      }, 100);
+    }
+  }, [selectedId, onSelectedConsumed]);
+
+  /* ── AI Discovery ────────────────────────────────────────────────── */
+  async function discoverGrants() {
+    setDiscovering(true);
+    try {
+      const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+      if (!res.ok) throw new Error('Discovery failed');
+      const data = await res.json();
+      addToast(`Discovered ${data.discovered || 0} new grants`, 'success');
+      fetchGrants();
+      fetchDashStats();
+    } catch (err) {
+      console.error('Discovery error:', err);
+      addToast('Grant discovery failed — check Grant app status', 'error');
+    } finally {
+      setDiscovering(false);
+    }
+  }
+
+  /* ── Actions ─────────────────────────────────────────────────────── */
+  async function toggleBookmark(grant: Grant) {
+    try {
+      const res = await fetch('/api/grants-proxy/grants', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ id: grant.id, is_bookmarked: !grant.is_bookmarked }),
+      });
+      if (!res.ok) throw new Error('Bookmark failed');
+      setGrants((prev) =>
+        prev.map((g) => (g.id === grant.id ? { ...g, is_bookmarked: !g.is_bookmarked } : g))
+      );
+    } catch {
+      addToast('Failed to update bookmark', 'error');
+    }
+  }
+
+  async function updateStatus(grantId: string, status: string) {
+    try {
+      const res = await fetch('/api/grants-proxy/grants', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ id: grantId, status }),
+      });
+      if (!res.ok) throw new Error('Status update failed');
+      addToast(`Status updated to ${STATUS_LABELS[status] || status}`, 'success');
+      fetchGrants();
+    } catch {
+      addToast('Failed to update status', 'error');
+    }
+  }
+
+  /* ─── Render ─────────────────────────────────────────────────────── */
+  if (loading) return <SkeletonList count={6} />;
+
+  const statusFilters = ['all', 'discovered', 'researching', 'preparing', 'submitted', 'awarded', 'declined', 'expired'];
+
+  return (
+    <div style={{ padding: 24, maxWidth: 1200 }}>
+      {/* ── Header ─────────────────────────────────────────────────── */}
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20, flexWrap: 'wrap', gap: 12 }}>
+        <div>
+          <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+            Grants
+            <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+              {statusCounts.all ?? grants.length} total
+            </span>
+          </h2>
+        </div>
+        <button
+          onClick={discoverGrants}
+          disabled={discovering}
+          style={{
+            display: 'flex', alignItems: 'center', gap: 6,
+            padding: '8px 16px', borderRadius: 8, border: 'none',
+            backgroundColor: '#059669', color: '#fff',
+            fontSize: 13, fontWeight: 600, cursor: discovering ? 'wait' : 'pointer',
+            opacity: discovering ? 0.7 : 1,
+            transition: 'opacity 0.15s',
+          }}
+        >
+          {discovering ? <Loader2 size={15} style={{ animation: 'spin 1s linear infinite' }} /> : <Sparkles size={15} />}
+          {discovering ? 'Discovering…' : 'Discover Grants'}
+        </button>
+      </div>
+
+      {/* ── Quick Stats ────────────────────────────────────────────── */}
+      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 10, marginBottom: 20 }}>
+        {[
+          { label: 'Total Grants', value: statusCounts.all ?? 0, icon: DollarSign, color: '#059669' },
+          { label: 'Submitted', value: statusCounts.submitted ?? 0, icon: Target, color: '#06b6d4' },
+          { label: 'Awarded', value: statusCounts.awarded ?? 0, icon: TrendingUp, color: '#22c55e' },
+          { label: 'Total Funding', value: dashStats?.total_funding ? `$${dashStats.total_funding.toLocaleString()}` : '$0', icon: DollarSign, color: '#10b981' },
+        ].map((s) => (
+          <div key={s.label} style={{
+            padding: '14px 16px', borderRadius: 10,
+            backgroundColor: 'var(--color-surface)',
+            border: '1px solid var(--color-border)',
+            display: 'flex', alignItems: 'center', gap: 12,
+          }}>
+            <div style={{
+              width: 36, height: 36, borderRadius: 8,
+              backgroundColor: s.color + '18',
+              display: 'flex', alignItems: 'center', justifyContent: 'center',
+            }}>
+              <s.icon size={18} style={{ color: s.color }} />
+            </div>
+            <div>
+              <div style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 500 }}>{s.label}</div>
+              <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
+            </div>
+          </div>
+        ))}
+      </div>
+
+      {/* ── Search ─────────────────────────────────────────────────── */}
+      <div style={{ marginBottom: 16, position: 'relative' }}>
+        <Search size={14} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
+        <input
+          type="text"
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          placeholder="Search grants by title, funder, or tags…"
+          style={{
+            width: '100%', padding: '8px 12px 8px 34px', borderRadius: 8,
+            border: '1px solid var(--color-border)',
+            backgroundColor: 'var(--color-surface)',
+            color: 'var(--color-text)', fontSize: 13,
+            outline: 'none',
+          }}
+        />
+      </div>
+
+      {/* ── Status Pipeline Tabs ───────────────────────────────────── */}
+      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
+        {statusFilters.map((s) => {
+          const isActive = filter === s;
+          const color = s === 'all' ? '#7c3aed' : STATUS_COLORS[s] || '#6b7280';
+          const count = s === 'all' ? (statusCounts.all ?? grants.length) : (statusCounts[s] ?? 0);
+          return (
+            <button
+              key={s}
+              onClick={() => setFilter(isActive ? 'all' : s)}
+              style={{
+                display: 'flex', alignItems: 'center', gap: 6,
+                padding: '5px 12px', borderRadius: 20, cursor: 'pointer',
+                border: isActive ? `2px solid ${color}` : '1px solid var(--color-border)',
+                backgroundColor: isActive ? color + '22' : 'var(--color-surface)',
+                color: isActive ? color : 'var(--color-text-muted)',
+                fontSize: 12, fontWeight: 500,
+                transition: 'all 0.15s',
+              }}
+            >
+              {s !== 'all' && (
+                <span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: color }} />
+              )}
+              {s === 'all' ? 'All' : STATUS_LABELS[s] || s} ({count})
+            </button>
+          );
+        })}
+      </div>
+
+      {/* ── Grant Cards ────────────────────────────────────────────── */}
+      <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
+        {grants.map((grant) => {
+          const isExpanded = expanded === grant.id;
+          return (
+            <div
+              key={grant.id}
+              id={`grant-${grant.id}`}
+              style={{
+                borderRadius: 10,
+                backgroundColor: 'var(--color-surface)',
+                border: isExpanded ? '1px solid #059669' : '1px solid var(--color-border)',
+                transition: 'border-color 0.15s',
+                overflow: 'hidden',
+              }}
+            >
+              {/* Card Header */}
+              <div
+                style={{
+                  padding: '14px 16px', cursor: 'pointer',
+                  display: 'flex', alignItems: 'flex-start', gap: 12,
+                }}
+                onClick={() => setExpanded(isExpanded ? null : grant.id)}
+              >
+                {/* Left: status dot + content */}
+                <div style={{ flex: 1, minWidth: 0 }}>
+                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
+                    <span style={{
+                      width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
+                      backgroundColor: STATUS_COLORS[grant.status] || '#6b7280',
+                    }} />
+                    <span style={{
+                      fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
+                      overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
+                    }}>
+                      {grant.title}
+                    </span>
+                  </div>
+
+                  <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 12, color: 'var(--color-text-muted)', flexWrap: 'wrap' }}>
+                    <span style={{ fontWeight: 500 }}>{grant.funder}</span>
+                    <span style={{ color: '#059669', fontWeight: 600 }}>{formatAmount(grant.amount_min, grant.amount_max)}</span>
+                    {grant.deadline && (
+                      <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+                        <Calendar size={11} />
+                        {new Date(grant.deadline).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+                      </span>
+                    )}
+                    {grant.ai_fit_score != null && (
+                      <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+                        <Sparkles size={11} style={{ color: '#f59e0b' }} />
+                        {Math.round(grant.ai_fit_score * 100)}% fit
+                      </span>
+                    )}
+                  </div>
+                </div>
+
+                {/* Right: badges + chevron */}
+                <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
+                  <span style={{
+                    fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
+                    backgroundColor: (STATUS_COLORS[grant.status] || '#6b7280') + '22',
+                    color: STATUS_COLORS[grant.status] || '#6b7280',
+                    textTransform: 'uppercase',
+                  }}>
+                    {STATUS_LABELS[grant.status] || grant.status}
+                  </span>
+                  <span style={{
+                    fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 4,
+                    backgroundColor: (PRIORITY_COLORS[grant.priority] || '#6b7280') + '18',
+                    color: PRIORITY_COLORS[grant.priority] || '#6b7280',
+                    textTransform: 'uppercase',
+                  }}>
+                    {grant.priority}
+                  </span>
+                  <button
+                    onClick={(e) => { e.stopPropagation(); toggleBookmark(grant); }}
+                    style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
+                    title={grant.is_bookmarked ? 'Remove bookmark' : 'Bookmark'}
+                  >
+                    {grant.is_bookmarked
+                      ? <BookmarkCheck size={16} style={{ color: '#f59e0b' }} />
+                      : <Bookmark size={16} style={{ color: 'var(--color-text-muted)' }} />
+                    }
+                  </button>
+                  {isExpanded ? <ChevronUp size={16} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={16} style={{ color: 'var(--color-text-muted)' }} />}
+                </div>
+              </div>
+
+              {/* Expanded Detail */}
+              {isExpanded && (
+                <div style={{ padding: '0 16px 16px', borderTop: '1px solid var(--color-border)' }}>
+                  {/* AI Fit Score Bar */}
+                  {grant.ai_fit_score != null && (
+                    <div style={{ marginTop: 12, marginBottom: 12 }}>
+                      <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>
+                        AI Fit Score
+                      </div>
+                      <div style={{
+                        height: 6, borderRadius: 3,
+                        backgroundColor: 'var(--color-border)',
+                        overflow: 'hidden',
+                      }}>
+                        <div style={{
+                          height: '100%', borderRadius: 3,
+                          width: `${Math.round(grant.ai_fit_score * 100)}%`,
+                          backgroundColor: grant.ai_fit_score >= 0.7 ? '#22c55e' : grant.ai_fit_score >= 0.4 ? '#f59e0b' : '#ef4444',
+                          transition: 'width 0.3s ease',
+                        }} />
+                      </div>
+                    </div>
+                  )}
+
+                  {/* AI Suggestion */}
+                  {grant.ai_suggestion && (
+                    <div style={{
+                      padding: '10px 12px', borderRadius: 8, marginBottom: 12,
+                      backgroundColor: '#059669' + '12',
+                      border: '1px solid #059669' + '30',
+                      fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5,
+                    }}>
+                      <strong style={{ color: '#10b981' }}>AI Suggestion:</strong> {grant.ai_suggestion}
+                    </div>
+                  )}
+
+                  {/* Description */}
+                  {grant.description && (
+                    <div style={{ marginBottom: 12 }}>
+                      <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                        Description
+                      </div>
+                      <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
+                        {grant.description}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Eligibility */}
+                  {grant.eligibility && (
+                    <div style={{ marginBottom: 12 }}>
+                      <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                        Eligibility
+                      </div>
+                      <div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
+                        {grant.eligibility}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Focus Areas */}
+                  {grant.focus_areas?.length > 0 && (
+                    <div style={{ marginBottom: 12 }}>
+                      <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                        Focus Areas
+                      </div>
+                      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+                        {grant.focus_areas.map((area) => (
+                          <span key={area} style={{
+                            padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 500,
+                            backgroundColor: '#059669' + '18', color: '#10b981',
+                          }}>
+                            {area}
+                          </span>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Tags */}
+                  {grant.tags?.length > 0 && (
+                    <div style={{ marginBottom: 12 }}>
+                      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+                        {grant.tags.map((tag) => (
+                          <span key={tag} style={{
+                            display: 'flex', alignItems: 'center', gap: 3,
+                            padding: '2px 8px', borderRadius: 4, fontSize: 11,
+                            backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
+                          }}>
+                            <Tag size={9} /> {tag}
+                          </span>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Notes */}
+                  {grant.notes && (
+                    <div style={{ marginBottom: 12 }}>
+                      <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                        Notes
+                      </div>
+                      <div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5, fontStyle: 'italic' }}>
+                        {grant.notes}
+                      </div>
+                    </div>
+                  )}
+
+                  {/* Meta row */}
+                  <div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 12, flexWrap: 'wrap' }}>
+                    {grant.cycle && (
+                      <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+                        <Clock size={11} /> Cycle: {grant.cycle}
+                      </span>
+                    )}
+                    <span>
+                      Added {new Date(grant.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+                    </span>
+                  </div>
+
+                  {/* Actions row */}
+                  <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
+                    {grant.application_url && (
+                      <a
+                        href={grant.application_url}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        style={{
+                          display: 'flex', alignItems: 'center', gap: 4,
+                          padding: '6px 12px', borderRadius: 6,
+                          backgroundColor: '#059669', color: '#fff',
+                          fontSize: 12, fontWeight: 600, textDecoration: 'none',
+                        }}
+                      >
+                        <ExternalLink size={12} /> Apply
+                      </a>
+                    )}
+                    {grant.funder_url && (
+                      <a
+                        href={grant.funder_url}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        style={{
+                          display: 'flex', alignItems: 'center', gap: 4,
+                          padding: '6px 12px', borderRadius: 6,
+                          border: '1px solid var(--color-border)',
+                          backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+                          fontSize: 12, fontWeight: 500, textDecoration: 'none',
+                        }}
+                      >
+                        <ExternalLink size={12} /> Funder Site
+                      </a>
+                    )}
+
+                    {/* Status change dropdown */}
+                    <select
+                      value={grant.status}
+                      onChange={(e) => updateStatus(grant.id, e.target.value)}
+                      style={{
+                        padding: '6px 10px', borderRadius: 6, fontSize: 12,
+                        border: '1px solid var(--color-border)',
+                        backgroundColor: 'var(--color-surface)',
+                        color: 'var(--color-text)',
+                        cursor: 'pointer',
+                      }}
+                    >
+                      {Object.entries(STATUS_LABELS).map(([val, label]) => (
+                        <option key={val} value={val}>{label}</option>
+                      ))}
+                    </select>
+                  </div>
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+
+      {/* ── Empty state ────────────────────────────────────────────── */}
+      {grants.length === 0 && !loading && (
+        <div style={{
+          padding: 48, textAlign: 'center',
+          color: 'var(--color-text-muted)',
+        }}>
+          <DollarSign size={40} style={{ marginBottom: 12, opacity: 0.3 }} />
+          <div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>No grants found</div>
+          <div style={{ fontSize: 13, marginBottom: 16 }}>
+            {filter !== 'all' ? 'Try a different status filter.' : 'Click "Discover Grants" to find funding opportunities with AI.'}
+          </div>
+          {filter === 'all' && (
+            <button
+              onClick={discoverGrants}
+              disabled={discovering}
+              style={{
+                padding: '10px 20px', borderRadius: 8, border: 'none',
+                backgroundColor: '#059669', color: '#fff',
+                fontSize: 13, fontWeight: 600, cursor: 'pointer',
+              }}
+            >
+              <Sparkles size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} />
+              Discover Grants
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/grants/types.ts b/components/grants/types.ts
new file mode 100644
index 0000000..f761e58
--- /dev/null
+++ b/components/grants/types.ts
@@ -0,0 +1,51 @@
+export interface Grant {
+  id: string;
+  title: string;
+  funder: string;
+  funder_url: string | null;
+  amount_min: number | null;
+  amount_max: number | null;
+  description: string | null;
+  eligibility: string | null;
+  focus_areas: string[];
+  application_url: string | null;
+  deadline: string | null;
+  cycle: string | null;
+  ai_fit_score: number | null;
+  ai_suggestion: string | null;
+  status: string;
+  priority: string;
+  notes: string | null;
+  tags: string[];
+  is_bookmarked: boolean;
+  created_at: string;
+  updated_at?: string;
+}
+
+export function formatAmount(min: number | null, max: number | null): string {
+  if (!min && !max) return 'TBD';
+  if (min && max && min === max) return `$${max.toLocaleString()}`;
+  if (min && max) return `$${min.toLocaleString()} – $${max.toLocaleString()}`;
+  if (max) return `Up to $${max.toLocaleString()}`;
+  return `From $${min!.toLocaleString()}`;
+}
+
+export const STATUS_COLORS: Record<string, string> = {
+  discovered: '#3b82f6',
+  researching: '#8b5cf6',
+  preparing: '#f59e0b',
+  submitted: '#06b6d4',
+  awarded: '#22c55e',
+  declined: '#ef4444',
+  expired: '#6b7280',
+};
+
+export const STATUS_LABELS: Record<string, string> = {
+  discovered: 'Discovered',
+  researching: 'Researching',
+  preparing: 'Preparing',
+  submitted: 'Submitted',
+  awarded: 'Awarded',
+  declined: 'Declined',
+  expired: 'Expired',
+};
diff --git a/components/heatmap/HeatMapTab.tsx b/components/heatmap/HeatMapTab.tsx
new file mode 100644
index 0000000..13a8021
--- /dev/null
+++ b/components/heatmap/HeatMapTab.tsx
@@ -0,0 +1,709 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import { Loader2, MapPin, Network, DollarSign, Megaphone } from 'lucide-react';
+import { Grant } from '../grants/types';
+
+interface StateData {
+  state: string;
+  topics: string[] | null;
+  intensity: number;
+  sentiment: number;
+  article_count: number;
+}
+
+interface TopicData {
+  id: string;
+  title: string;
+  category: string;
+  geo_states: string[];
+  urgency: string;
+  article_count: number;
+  avg_sentiment: number;
+}
+
+interface PetitionData {
+  id: string;
+  title: string;
+  category: string | null;
+  tags: string[] | null;
+  status: string;
+  signature_count: number;
+}
+
+const STATE_NAMES: Record<string, string> = {
+  AL:'Alabama',AK:'Alaska',AZ:'Arizona',AR:'Arkansas',CA:'California',CO:'Colorado',
+  CT:'Connecticut',DE:'Delaware',FL:'Florida',GA:'Georgia',HI:'Hawaii',ID:'Idaho',
+  IL:'Illinois',IN:'Indiana',IA:'Iowa',KS:'Kansas',KY:'Kentucky',LA:'Louisiana',
+  ME:'Maine',MD:'Maryland',MA:'Massachusetts',MI:'Michigan',MN:'Minnesota',MS:'Mississippi',
+  MO:'Missouri',MT:'Montana',NE:'Nebraska',NV:'Nevada',NH:'New Hampshire',NJ:'New Jersey',
+  NM:'New Mexico',NY:'New York',NC:'North Carolina',ND:'North Dakota',OH:'Ohio',OK:'Oklahoma',
+  OR:'Oregon',PA:'Pennsylvania',RI:'Rhode Island',SC:'South Carolina',SD:'South Dakota',
+  TN:'Tennessee',TX:'Texas',UT:'Utah',VT:'Vermont',VA:'Virginia',WA:'Washington',
+  WV:'West Virginia',WI:'Wisconsin',WY:'Wyoming',DC:'Dist. of Columbia',
+};
+
+/* ─── Tile Grid Map Positions ────────────────────────────────────────────────
+   Standard FiveThirtyEight-style tile grid. Each state gets [col, row].
+   Grid is 12 columns × 8 rows. */
+const TILE_POS: Record<string, [number, number]> = {
+  AK:[0,0], ME:[11,0],
+  WI:[6,1], VT:[10,1], NH:[11,1],
+  WA:[1,1], ID:[2,1], MT:[3,1], ND:[4,1], MN:[5,1], IL:[6,2], MI:[7,1], NY:[9,1], MA:[10,2], RI:[11,2],
+  OR:[1,2], NV:[2,2], WY:[3,2], SD:[4,2], IA:[5,2], IN:[7,2], OH:[8,2], PA:[9,2], NJ:[10,3], CT:[11,3],
+  CA:[1,3], UT:[2,3], CO:[3,3], NE:[4,3], MO:[5,3], KY:[6,3], WV:[7,3], VA:[8,3], MD:[9,3], DC:[10,4],
+  AZ:[2,4], NM:[3,4], KS:[4,4], AR:[5,4], TN:[6,4], NC:[7,4], SC:[8,4], DE:[9,4],
+  OK:[4,5], LA:[5,5], MS:[6,5], AL:[7,5], GA:[8,5],
+  HI:[1,6], TX:[4,6], FL:[9,5],
+};
+
+/* ─── Color Scales ──────────────────────────────────────────────────────────── */
+function heatColor(articles: number, maxArticles: number): string {
+  if (maxArticles === 0) return '#1e1b2e';
+  const t = Math.min(1, articles / maxArticles);
+  // Dark purple → Blue → Yellow → Red gradient
+  if (t < 0.15) return '#2d1f5e';
+  if (t < 0.3)  return '#3b2d8b';
+  if (t < 0.45) return '#4f46b8';
+  if (t < 0.6)  return '#6366f1';
+  if (t < 0.75) return '#f59e0b';
+  if (t < 0.9)  return '#f97316';
+  return '#ef4444';
+}
+
+function sentimentColor(s: number): string {
+  if (s > 0.1) return '#22c55e';
+  if (s < -0.1) return '#ef4444';
+  return '#6b7280';
+}
+
+/* ─── Topic colors for mind map ─────────────────────────────────────────────── */
+const TOPIC_COLORS = [
+  '#8b5cf6', '#3b82f6', '#06b6d4', '#10b981', '#f59e0b',
+  '#ef4444', '#ec4899', '#f97316', '#14b8a6', '#a78bfa',
+];
+
+export default function HeatMapTab() {
+  const [states, setStates] = useState<StateData[]>([]);
+  const [topics, setTopics] = useState<TopicData[]>([]);
+  const [petitions, setPetitions] = useState<PetitionData[]>([]);
+  const [grants, setGrants] = useState<Grant[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [selected, setSelected] = useState<StateData | null>(null);
+  const [view, setView] = useState<'map' | 'mind'>('map');
+  const [hoveredNode, setHoveredNode] = useState<string | null>(null);
+
+  useEffect(() => {
+    Promise.all([
+      fetch('/api/pulse/geo').then(r => r.ok ? r.json() : { states: [] }),
+      fetch('/api/pulse/topics').then(r => r.ok ? r.json() : { topics: [] }),
+      fetch('/api/petitions').then(r => r.ok ? r.json() : { rows: [] }),
+      fetch('/api/grants-proxy/grants').then(r => r.ok ? r.json() : { rows: [] }).catch(() => ({ rows: [] })),
+    ]).then(([geoData, topicData, petData, grantData]) => {
+      setStates(geoData.states || []);
+      setTopics((topicData.topics || []).filter((t: TopicData) => t.geo_states?.length > 0));
+      setPetitions(petData.rows || []);
+      setGrants(grantData.rows || []);
+    }).catch(err => console.error('HeatMap load error:', err))
+      .finally(() => setLoading(false));
+  }, []);
+
+  const stateMap = useMemo(() => {
+    const m: Record<string, StateData> = {};
+    states.forEach(s => { m[s.state] = s; });
+    return m;
+  }, [states]);
+
+  const maxArticles = useMemo(() => {
+    return Math.max(1, ...states.map(s => Number(s.article_count)));
+  }, [states]);
+
+  if (loading) {
+    return (
+      <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+        <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading geographic data...
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ padding: 24, maxWidth: 1400 }}>
+      {/* Header */}
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
+        <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+          Heat Map
+          <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+            {states.length} states with activity
+          </span>
+        </h2>
+        {/* View Toggle */}
+        <div style={{ display: 'flex', gap: 4, padding: 3, borderRadius: 10, backgroundColor: 'var(--color-bg)', border: '1px solid var(--color-border)' }}>
+          <button onClick={() => setView('map')} style={{
+            padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+            backgroundColor: view === 'map' ? '#7c3aed' : 'transparent',
+            color: view === 'map' ? '#fff' : 'var(--color-text-muted)',
+          }}>
+            <MapPin size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> US Map
+          </button>
+          <button onClick={() => setView('mind')} style={{
+            padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer',
+            backgroundColor: view === 'mind' ? '#7c3aed' : 'transparent',
+            color: view === 'mind' ? '#fff' : 'var(--color-text-muted)',
+          }}>
+            <Network size={13} style={{ marginRight: 4, verticalAlign: -2 }} /> Network
+          </button>
+        </div>
+      </div>
+
+      {states.length === 0 ? (
+        <div style={{
+          padding: 40, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 14,
+          backgroundColor: 'var(--color-surface)', borderRadius: 12, border: '1px solid var(--color-border)',
+        }}>
+          <MapPin size={32} style={{ marginBottom: 8, opacity: 0.4 }} />
+          <div>No geographic data yet. Run topic generation to populate state-level data.</div>
+        </div>
+      ) : view === 'map' ? (
+        <TileGridMap states={states} stateMap={stateMap} maxArticles={maxArticles} selected={selected} onSelect={setSelected} />
+      ) : (
+        <NetworkMindMap topics={topics} petitions={petitions} grants={grants} stateMap={stateMap} hoveredNode={hoveredNode} onHoverNode={setHoveredNode} />
+      )}
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════════
+   TILE GRID MAP — Geographic US Map with colored tiles
+   ═══════════════════════════════════════════════════════════════════════════════ */
+function TileGridMap({ states, stateMap, maxArticles, selected, onSelect }: {
+  states: StateData[];
+  stateMap: Record<string, StateData>;
+  maxArticles: number;
+  selected: StateData | null;
+  onSelect: (s: StateData | null) => void;
+}) {
+  const TILE = 58;
+  const GAP = 4;
+  const STEP = TILE + GAP;
+  const COLS = 12;
+  const ROWS = 7;
+  const W = COLS * STEP + 40;
+  const H = ROWS * STEP + 40;
+
+  return (
+    <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
+      {/* Map */}
+      <div style={{
+        flex: '1 1 700px', padding: 20, borderRadius: 12,
+        backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+        overflow: 'auto',
+      }}>
+        <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 800, display: 'block', margin: '0 auto' }}>
+          {/* All 50 states + DC */}
+          {Object.entries(TILE_POS).map(([abbr, [col, row]]) => {
+            const data = stateMap[abbr];
+            const x = col * STEP + 20;
+            const y = row * STEP + 10;
+            const articles = data ? Number(data.article_count) : 0;
+            const fill = data ? heatColor(articles, maxArticles) : '#1a1726';
+            const isSelected = selected?.state === abbr;
+            const sentiment = data ? Number(data.sentiment) : 0;
+
+            return (
+              <g key={abbr} onClick={() => data && onSelect(isSelected ? null : data)} style={{ cursor: data ? 'pointer' : 'default' }}>
+                <rect
+                  x={x} y={y} width={TILE} height={TILE} rx={6}
+                  fill={fill}
+                  stroke={isSelected ? '#a78bfa' : data ? '#ffffff15' : '#ffffff08'}
+                  strokeWidth={isSelected ? 2.5 : 1}
+                />
+                <text
+                  x={x + TILE / 2} y={y + TILE / 2 - 4}
+                  textAnchor="middle" dominantBaseline="middle"
+                  fill={data ? '#fff' : '#555'}
+                  fontSize={14} fontWeight={700} fontFamily="system-ui"
+                >
+                  {abbr}
+                </text>
+                {data && (
+                  <text
+                    x={x + TILE / 2} y={y + TILE / 2 + 12}
+                    textAnchor="middle" dominantBaseline="middle"
+                    fill="#ffffff99" fontSize={8} fontFamily="system-ui"
+                  >
+                    {articles}
+                  </text>
+                )}
+                {/* Sentiment indicator dot */}
+                {data && (
+                  <circle
+                    cx={x + TILE - 6} cy={y + 6} r={3}
+                    fill={sentimentColor(sentiment)}
+                  />
+                )}
+              </g>
+            );
+          })}
+        </svg>
+
+        {/* Legend */}
+        <div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 16, flexWrap: 'wrap' }}>
+          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+            <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Low</span>
+            {['#2d1f5e', '#3b2d8b', '#4f46b8', '#6366f1', '#f59e0b', '#f97316', '#ef4444'].map((c, i) => (
+              <span key={i} style={{ width: 20, height: 10, borderRadius: 2, backgroundColor: c }} />
+            ))}
+            <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>High</span>
+          </div>
+          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+            <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+              <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#22c55e' }} />
+              <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Positive</span>
+            </span>
+            <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+              <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#6b7280' }} />
+              <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Neutral</span>
+            </span>
+            <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
+              <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: '#ef4444' }} />
+              <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>Negative</span>
+            </span>
+          </div>
+        </div>
+      </div>
+
+      {/* Detail Panel */}
+      {selected && (
+        <div style={{
+          flex: '0 0 300px', padding: 20, borderRadius: 12,
+          backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+          alignSelf: 'flex-start',
+        }}>
+          <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
+            <div style={{
+              width: 40, height: 40, borderRadius: 10,
+              backgroundColor: heatColor(Number(selected.article_count), maxArticles),
+              display: 'flex', alignItems: 'center', justifyContent: 'center',
+              color: '#fff', fontSize: 16, fontWeight: 800,
+            }}>{selected.state}</div>
+            <div>
+              <div style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
+                {STATE_NAMES[selected.state] || selected.state}
+              </div>
+              <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+                {Number(selected.article_count)} articles | Intensity: {Number(selected.intensity).toFixed(0)}
+              </div>
+            </div>
+          </div>
+
+          {/* Sentiment bar */}
+          <div style={{ marginBottom: 16 }}>
+            <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>Sentiment</div>
+            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+              <div style={{
+                flex: 1, height: 6, borderRadius: 3, backgroundColor: 'var(--color-bg)',
+                overflow: 'hidden', position: 'relative',
+              }}>
+                <div style={{
+                  position: 'absolute',
+                  left: '50%', top: 0, bottom: 0,
+                  width: `${Math.abs(Number(selected.sentiment)) * 200}%`,
+                  transform: Number(selected.sentiment) < 0 ? 'translateX(-100%)' : 'none',
+                  backgroundColor: sentimentColor(Number(selected.sentiment)),
+                  borderRadius: 3,
+                }} />
+              </div>
+              <span style={{
+                fontSize: 12, fontWeight: 600,
+                color: sentimentColor(Number(selected.sentiment)),
+              }}>
+                {Number(selected.sentiment).toFixed(3)}
+              </span>
+            </div>
+          </div>
+
+          {/* Topics list */}
+          {selected.topics && selected.topics.length > 0 && (
+            <div>
+              <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
+                Source Categories ({selected.topics.length})
+              </div>
+              <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
+                {selected.topics.map((t, i) => (
+                  <span key={i} style={{
+                    fontSize: 10, padding: '3px 8px', borderRadius: 8,
+                    backgroundColor: '#7c3aed22', color: '#a78bfa',
+                  }}>
+                    {t.replace(/_/g, ' ')}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════════
+   NETWORK MIND MAP — 3-ring radial network: Topics → Petitions+Grants → States
+   ═══════════════════════════════════════════════════════════════════════════════ */
+const NODE_COLORS = {
+  topic: '#8b5cf6',
+  petition: '#3b82f6',
+  grant: '#10b981',
+  state: '#6b7280',
+};
+
+function textMatch(a: string | null | undefined, b: string | null | undefined): boolean {
+  if (!a || !b) return false;
+  return a.toLowerCase().includes(b.toLowerCase()) || b.toLowerCase().includes(a.toLowerCase());
+}
+
+function tagsOverlap(a: string[] | null | undefined, b: string[] | null | undefined): boolean {
+  if (!a?.length || !b?.length) return false;
+  const setB = new Set(b.map(t => t.toLowerCase()));
+  return a.some(t => setB.has(t.toLowerCase()));
+}
+
+function NetworkMindMap({ topics, petitions, grants, stateMap, hoveredNode, onHoverNode }: {
+  topics: TopicData[];
+  petitions: PetitionData[];
+  grants: Grant[];
+  stateMap: Record<string, StateData>;
+  hoveredNode: string | null;
+  onHoverNode: (id: string | null) => void;
+}) {
+  const displayTopics = topics.slice(0, 10);
+  const displayPetitions = petitions.slice(0, 8);
+  const displayGrants = grants.slice(0, 8);
+  const CX = 500;
+  const CY = 400;
+  const R1 = 100;  // Topics ring
+  const R2 = 220;  // Petitions + Grants ring
+  const R3 = 340;  // States ring
+  const W = 1000;
+  const H = 800;
+
+  // Collect unique states
+  const stateSet = new Set<string>();
+  displayTopics.forEach(t => t.geo_states?.forEach(s => stateSet.add(s)));
+  const uniqueStates = Array.from(stateSet);
+
+  // Positions: Ring 3 — States
+  const statePos: Record<string, { x: number; y: number }> = {};
+  uniqueStates.forEach((s, i) => {
+    const angle = (i / uniqueStates.length) * Math.PI * 2 - Math.PI / 2;
+    statePos[s] = { x: CX + Math.cos(angle) * R3, y: CY + Math.sin(angle) * R3 };
+  });
+
+  // Positions: Ring 1 — Topics
+  const topicNodes = displayTopics.map((t, i) => {
+    const angle = (i / displayTopics.length) * Math.PI * 2 - Math.PI / 2;
+    return { id: `t-${t.id}`, data: t, x: CX + Math.cos(angle) * R1, y: CY + Math.sin(angle) * R1, type: 'topic' as const };
+  });
+
+  // Positions: Ring 2 — Petitions (left half) + Grants (right half)
+  const midCount = displayPetitions.length + displayGrants.length;
+  const petitionNodes = displayPetitions.map((p, i) => {
+    const angle = (i / midCount) * Math.PI * 2 - Math.PI / 2;
+    return { id: `p-${p.id}`, data: p, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'petition' as const };
+  });
+  const grantNodes = displayGrants.map((g, i) => {
+    const angle = ((i + displayPetitions.length) / midCount) * Math.PI * 2 - Math.PI / 2;
+    return { id: `g-${g.id}`, data: g, x: CX + Math.cos(angle) * R2, y: CY + Math.sin(angle) * R2, type: 'grant' as const };
+  });
+
+  // Build connections
+  type Connection = { from: string; to: string; fx: number; fy: number; tx: number; ty: number; color: string };
+  const connections: Connection[] = [];
+
+  // Topic → State (from geo_states)
+  topicNodes.forEach(tn => {
+    (tn.data as TopicData).geo_states?.forEach(s => {
+      const sp = statePos[s];
+      if (sp) connections.push({ from: tn.id, to: `s-${s}`, fx: tn.x, fy: tn.y, tx: sp.x, ty: sp.y, color: NODE_COLORS.topic });
+    });
+  });
+
+  // Topic → Petition (matched by category)
+  topicNodes.forEach(tn => {
+    const topicCat = (tn.data as TopicData).category;
+    petitionNodes.forEach(pn => {
+      const petCat = (pn.data as PetitionData).category;
+      const petTags = (pn.data as PetitionData).tags;
+      if (textMatch(topicCat, petCat) || (petTags && petTags.some(tag => textMatch(topicCat, tag)))) {
+        connections.push({ from: tn.id, to: pn.id, fx: tn.x, fy: tn.y, tx: pn.x, ty: pn.y, color: NODE_COLORS.petition });
+      }
+    });
+  });
+
+  // Topic → Grant (matched by focus_areas/category)
+  topicNodes.forEach(tn => {
+    const topicCat = (tn.data as TopicData).category;
+    grantNodes.forEach(gn => {
+      const grantData = gn.data as Grant;
+      if (grantData.focus_areas?.some(fa => textMatch(topicCat, fa)) || tagsOverlap(grantData.tags, [topicCat])) {
+        connections.push({ from: tn.id, to: gn.id, fx: tn.x, fy: tn.y, tx: gn.x, ty: gn.y, color: NODE_COLORS.grant });
+      }
+    });
+  });
+
+  // Grant → Petition (matched by tags overlap)
+  grantNodes.forEach(gn => {
+    const grantTags = (gn.data as Grant).tags;
+    const grantFocus = (gn.data as Grant).focus_areas;
+    petitionNodes.forEach(pn => {
+      const petTags = (pn.data as PetitionData).tags;
+      if (tagsOverlap(grantTags, petTags) || tagsOverlap(grantFocus, petTags)) {
+        connections.push({ from: gn.id, to: pn.id, fx: gn.x, fy: gn.y, tx: pn.x, ty: pn.y, color: '#f59e0b' });
+      }
+    });
+  });
+
+  // Determine which nodes are connected to the hovered node
+  const connectedToHovered = useMemo(() => {
+    if (!hoveredNode) return new Set<string>();
+    const set = new Set<string>();
+    set.add(hoveredNode);
+    connections.forEach(c => {
+      if (c.from === hoveredNode) set.add(c.to);
+      if (c.to === hoveredNode) set.add(c.from);
+    });
+    return set;
+  }, [hoveredNode, connections]);
+
+  const isNodeDimmed = (nodeId: string) => hoveredNode !== null && !connectedToHovered.has(nodeId);
+  const isConnDimmed = (c: Connection) => hoveredNode !== null && !connectedToHovered.has(c.from) && !connectedToHovered.has(c.to);
+  const isConnHighlighted = (c: Connection) => hoveredNode !== null && (c.from === hoveredNode || c.to === hoveredNode);
+
+  return (
+    <div style={{
+      padding: 20, borderRadius: 12,
+      backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+    }}>
+      <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, textAlign: 'center' }}>
+        Topics ↔ Petitions ↔ Grants ↔ States — hover any node to highlight connections
+      </div>
+
+      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', maxWidth: 1000, display: 'block', margin: '0 auto' }}>
+        {/* Ring guides */}
+        <circle cx={CX} cy={CY} r={R1} fill="none" stroke="#ffffff06" strokeWidth={1} />
+        <circle cx={CX} cy={CY} r={R2} fill="none" stroke="#ffffff06" strokeWidth={1} />
+        <circle cx={CX} cy={CY} r={R3} fill="none" stroke="#ffffff06" strokeWidth={1} />
+
+        {/* Connection lines */}
+        {connections.map((c, i) => (
+          <line
+            key={i}
+            x1={c.fx} y1={c.fy} x2={c.tx} y2={c.ty}
+            stroke={c.color}
+            strokeWidth={isConnHighlighted(c) ? 2.5 : 1}
+            strokeOpacity={isConnDimmed(c) ? 0.03 : isConnHighlighted(c) ? 0.8 : 0.2}
+            strokeDasharray={isConnHighlighted(c) ? '' : '4 2'}
+          />
+        ))}
+
+        {/* Center hub */}
+        <circle cx={CX} cy={CY} r={28} fill="#7c3aed22" stroke="#7c3aed" strokeWidth={1.5} />
+        <text x={CX} y={CY - 5} textAnchor="middle" fill="#a78bfa" fontSize={9} fontWeight={700}>Pulse</text>
+        <text x={CX} y={CY + 7} textAnchor="middle" fill="#a78bfa88" fontSize={7}>Network</text>
+
+        {/* Ring 3: State nodes */}
+        {uniqueStates.map(s => {
+          const pos = statePos[s];
+          const data = stateMap[s];
+          const nodeId = `s-${s}`;
+          const dimmed = isNodeDimmed(nodeId);
+          return (
+            <g key={nodeId}
+              onMouseEnter={() => onHoverNode(nodeId)}
+              onMouseLeave={() => onHoverNode(null)}
+              style={{ cursor: 'pointer' }}
+            >
+              <circle
+                cx={pos.x} cy={pos.y} r={16}
+                fill={dimmed ? '#1e1b2e55' : '#1e1b2e'}
+                stroke={dimmed ? '#ffffff08' : '#ffffff20'}
+                strokeWidth={1}
+                opacity={dimmed ? 0.3 : 1}
+              />
+              <text
+                x={pos.x} y={pos.y - 1}
+                textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#aaa'}
+                fontSize={10} fontWeight={700} fontFamily="system-ui"
+              >
+                {s}
+              </text>
+              {data && (
+                <text
+                  x={pos.x} y={pos.y + 9}
+                  textAnchor="middle" dominantBaseline="middle"
+                  fill="#ffffff44" fontSize={6} fontFamily="system-ui"
+                >
+                  {Number(data.article_count)}
+                </text>
+              )}
+            </g>
+          );
+        })}
+
+        {/* Ring 2: Petition nodes */}
+        {petitionNodes.map(({ id, data, x, y }) => {
+          const dimmed = isNodeDimmed(id);
+          const pet = data as PetitionData;
+          const label = pet.title.length > 16 ? pet.title.substring(0, 14) + '…' : pet.title;
+          return (
+            <g key={id}
+              onMouseEnter={() => onHoverNode(id)}
+              onMouseLeave={() => onHoverNode(null)}
+              style={{ cursor: 'pointer' }}
+            >
+              <circle
+                cx={x} cy={y} r={22}
+                fill={dimmed ? NODE_COLORS.petition + '08' : NODE_COLORS.petition + '22'}
+                stroke={NODE_COLORS.petition}
+                strokeWidth={1.5}
+                opacity={dimmed ? 0.2 : 1}
+              />
+              <text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+              >
+                {label.split(' ').slice(0, 2).join(' ')}
+              </text>
+              <text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+              >
+                {label.split(' ').slice(2, 4).join(' ')}
+              </text>
+              {/* Petition icon badge */}
+              <circle cx={x + 16} cy={y - 16} r={6} fill={NODE_COLORS.petition} opacity={dimmed ? 0.2 : 1} />
+              <text x={x + 16} y={y - 16} textAnchor="middle" dominantBaseline="middle"
+                fill="#fff" fontSize={6} fontWeight={700}>P</text>
+            </g>
+          );
+        })}
+
+        {/* Ring 2: Grant nodes */}
+        {grantNodes.map(({ id, data, x, y }) => {
+          const dimmed = isNodeDimmed(id);
+          const g = data as Grant;
+          const label = g.title.length > 16 ? g.title.substring(0, 14) + '…' : g.title;
+          return (
+            <g key={id}
+              onMouseEnter={() => onHoverNode(id)}
+              onMouseLeave={() => onHoverNode(null)}
+              style={{ cursor: 'pointer' }}
+            >
+              <rect
+                x={x - 22} y={y - 16} width={44} height={32} rx={8}
+                fill={dimmed ? NODE_COLORS.grant + '08' : NODE_COLORS.grant + '22'}
+                stroke={NODE_COLORS.grant}
+                strokeWidth={1.5}
+                opacity={dimmed ? 0.2 : 1}
+              />
+              <text x={x} y={y - 3} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+              >
+                {label.split(' ').slice(0, 2).join(' ')}
+              </text>
+              <text x={x} y={y + 5} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={6} fontWeight={600} fontFamily="system-ui"
+              >
+                {label.split(' ').slice(2, 4).join(' ')}
+              </text>
+              {/* Grant icon badge */}
+              <circle cx={x + 18} cy={y - 12} r={6} fill={NODE_COLORS.grant} opacity={dimmed ? 0.2 : 1} />
+              <text x={x + 18} y={y - 12} textAnchor="middle" dominantBaseline="middle"
+                fill="#fff" fontSize={6} fontWeight={700}>$</text>
+            </g>
+          );
+        })}
+
+        {/* Ring 1: Topic nodes */}
+        {topicNodes.map(({ id, data, x, y }, i) => {
+          const dimmed = isNodeDimmed(id);
+          const topic = data as TopicData;
+          const color = TOPIC_COLORS[i % TOPIC_COLORS.length];
+          const shortTitle = topic.title.length > 20 ? topic.title.substring(0, 18) + '…' : topic.title;
+          return (
+            <g key={id}
+              onMouseEnter={() => onHoverNode(id)}
+              onMouseLeave={() => onHoverNode(null)}
+              style={{ cursor: 'pointer' }}
+            >
+              <circle
+                cx={x} cy={y} r={26}
+                fill={dimmed ? color + '08' : color + '22'}
+                stroke={color}
+                strokeWidth={dimmed ? 1 : 1.5}
+                opacity={dimmed ? 0.2 : 1}
+              />
+              {topic.urgency === 'critical' && !dimmed && (
+                <circle cx={x} cy={y} r={30} fill="none" stroke="#ef444466" strokeWidth={1} strokeDasharray="3 3" />
+              )}
+              <text x={x} y={y - 4} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
+              >
+                {shortTitle.split(' ').slice(0, 2).join(' ')}
+              </text>
+              <text x={x} y={y + 6} textAnchor="middle" dominantBaseline="middle"
+                fill={dimmed ? '#555' : '#fff'} fontSize={7} fontWeight={600} fontFamily="system-ui"
+              >
+                {shortTitle.split(' ').slice(2, 4).join(' ')}
+              </text>
+              <circle cx={x + 20} cy={y - 20} r={8} fill={color} opacity={dimmed ? 0.2 : 1} />
+              <text x={x + 20} y={y - 20} textAnchor="middle" dominantBaseline="middle"
+                fill="#fff" fontSize={7} fontWeight={700}
+              >
+                {topic.article_count}
+              </text>
+            </g>
+          );
+        })}
+      </svg>
+
+      {/* Legend */}
+      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 16, marginTop: 16, justifyContent: 'center' }}>
+        {[
+          { label: 'Topics', color: NODE_COLORS.topic, count: displayTopics.length },
+          { label: 'Petitions', color: NODE_COLORS.petition, count: displayPetitions.length },
+          { label: 'Grants', color: NODE_COLORS.grant, count: displayGrants.length },
+          { label: 'States', color: NODE_COLORS.state, count: uniqueStates.length },
+        ].map(item => (
+          <div key={item.label} style={{
+            display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px',
+            borderRadius: 8, backgroundColor: 'var(--color-bg)',
+            border: '1px solid var(--color-border)',
+          }}>
+            <span style={{ width: 10, height: 10, borderRadius: item.label === 'Grants' ? 3 : '50%', backgroundColor: item.color, flexShrink: 0 }} />
+            <span style={{ fontSize: 12, color: 'var(--color-text)', fontWeight: 600 }}>{item.label}</span>
+            <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>({item.count})</span>
+          </div>
+        ))}
+      </div>
+
+      {/* Node list — all entities */}
+      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 10, justifyContent: 'center' }}>
+        {topicNodes.map(({ id, data }, i) => (
+          <div key={id}
+            onMouseEnter={() => onHoverNode(id)}
+            onMouseLeave={() => onHoverNode(null)}
+            style={{
+              display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
+              borderRadius: 6, cursor: 'pointer', fontSize: 10,
+              backgroundColor: hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] + '22' : 'transparent',
+              border: `1px solid ${hoveredNode === id ? TOPIC_COLORS[i % TOPIC_COLORS.length] : 'transparent'}`,
+              color: 'var(--color-text-muted)',
+            }}
+          >
+            <span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: TOPIC_COLORS[i % TOPIC_COLORS.length] }} />
+            {(data as TopicData).title.substring(0, 30)}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
diff --git a/components/news/NewsFeedTab.tsx b/components/news/NewsFeedTab.tsx
new file mode 100644
index 0000000..ce960f6
--- /dev/null
+++ b/components/news/NewsFeedTab.tsx
@@ -0,0 +1,195 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { ExternalLink, Loader2, ChevronDown } from 'lucide-react';
+
+interface Article {
+  id: string;
+  title: string;
+  url: string;
+  author: string | null;
+  summary: string | null;
+  published_at: string;
+  sentiment: string;
+  sentiment_score: number;
+  geo_state: string | null;
+  tags: string[];
+  feed_name: string;
+  feed_category: string;
+  station: string | null;
+  article_num: number;
+}
+
+const CATEGORIES = ['all', 'national_tv', 'newspaper', 'local_tv', 'wire', 'blog', 'magazine', 'substack', 'government', 'regional'];
+const CAT_LABELS: Record<string, string> = {
+  all: 'All', national_tv: 'TV National', newspaper: 'Newspaper', local_tv: 'TV Local',
+  wire: 'Wire', blog: 'Blog', magazine: 'Magazine', substack: 'Substack',
+  government: 'Government', regional: 'Regional',
+};
+
+function sentimentDot(s: string) {
+  const colors: Record<string, string> = { positive: '#22c55e', negative: '#ef4444', neutral: '#6b7280' };
+  return <span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: colors[s] || '#6b7280', display: 'inline-block', flexShrink: 0 }} />;
+}
+
+function decodeEntities(str: string) {
+  if (!str) return str;
+  return str
+    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
+    .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
+    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'")
+    .replace(/\uFFFD+/g, "'");
+}
+
+export default function NewsFeedTab() {
+  const [articles, setArticles] = useState<Article[]>([]);
+  const [total, setTotal] = useState(0);
+  const [loading, setLoading] = useState(true);
+  const [loadingMore, setLoadingMore] = useState(false);
+  const [category, setCategory] = useState('all');
+  const [offset, setOffset] = useState(0);
+  const LIMIT = 50;
+
+  const fetchArticles = useCallback(async (reset = false) => {
+    const newOffset = reset ? 0 : offset;
+    reset ? setLoading(true) : setLoadingMore(true);
+    try {
+      const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
+      if (category !== 'all') params.set('category', category);
+      const res = await fetch(`/api/pulse/articles?${params}`);
+      if (res.ok) {
+        const data = await res.json();
+        const newArticles = data.articles || [];
+        if (reset) {
+          setArticles(newArticles);
+          setOffset(LIMIT);
+        } else {
+          setArticles(prev => [...prev, ...newArticles]);
+          setOffset(prev => prev + LIMIT);
+        }
+        setTotal(data.total || 0);
+      }
+    } catch (err) { console.error('Fetch articles error:', err); }
+    finally { setLoading(false); setLoadingMore(false); }
+  }, [category, offset]);
+
+  useEffect(() => { fetchArticles(true); }, [category]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  return (
+    <div style={{ padding: 24, maxWidth: 1200 }}>
+      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
+        <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+          News Feed
+          <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+            {total.toLocaleString()} articles
+          </span>
+        </h2>
+      </div>
+
+      {/* Category Filters */}
+      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 20 }}>
+        {CATEGORIES.map(cat => (
+          <button
+            key={cat}
+            onClick={() => setCategory(cat)}
+            style={{
+              padding: '5px 12px', borderRadius: 16, border: '1px solid',
+              borderColor: category === cat ? '#7c3aed' : 'var(--color-border)',
+              backgroundColor: category === cat ? '#7c3aed22' : 'transparent',
+              color: category === cat ? '#a78bfa' : 'var(--color-text-muted)',
+              fontSize: 12, fontWeight: 500, cursor: 'pointer',
+              transition: 'all 0.15s',
+            }}
+          >
+            {CAT_LABELS[cat] || cat}
+          </button>
+        ))}
+      </div>
+
+      {loading ? (
+        <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+          <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading articles...
+        </div>
+      ) : (
+        <>
+          <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
+            {articles.map(a => (
+              <div key={a.id} style={{
+                display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px',
+                backgroundColor: 'var(--color-surface)', borderBottom: '1px solid var(--color-border)',
+                transition: 'background-color 0.1s',
+              }}
+              onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+              onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface)'; }}
+              >
+                {/* Article # */}
+                <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500, minWidth: 36, textAlign: 'right', fontFamily: 'monospace' }}>
+                  #{a.article_num}
+                </span>
+
+                {/* Feed badge */}
+                <span style={{
+                  fontSize: 10, fontWeight: 600, padding: '2px 6px', borderRadius: 4,
+                  backgroundColor: '#7c3aed22', color: '#a78bfa', whiteSpace: 'nowrap', minWidth: 60, textAlign: 'center',
+                }}>
+                  {a.feed_name?.length > 14 ? a.feed_name.substring(0, 12) + '…' : a.feed_name}
+                </span>
+
+                {/* Title */}
+                <a
+                  href={a.url}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  style={{
+                    flex: 1, fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
+                    overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
+                    fontWeight: 500,
+                  }}
+                >
+                  {decodeEntities(a.title)}
+                </a>
+
+                {/* Sentiment dot */}
+                {sentimentDot(a.sentiment)}
+
+                {/* State */}
+                {a.geo_state && (
+                  <span style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 500 }}>{a.geo_state}</span>
+                )}
+
+                {/* Time */}
+                <span style={{ fontSize: 11, color: 'var(--color-text-muted)', whiteSpace: 'nowrap', minWidth: 55, textAlign: 'right' }}>
+                  {a.published_at ? new Date(a.published_at).toLocaleTimeString('en-US', { timeZone: 'America/Los_Angeles', hour: 'numeric', minute: '2-digit', hour12: true }) : ''}
+                </span>
+
+                {/* Link icon */}
+                <a href={a.url} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
+                  <ExternalLink size={13} />
+                </a>
+              </div>
+            ))}
+          </div>
+
+          {/* Load More */}
+          {articles.length < total && (
+            <div style={{ display: 'flex', justifyContent: 'center', padding: 20 }}>
+              <button
+                onClick={() => fetchArticles(false)}
+                disabled={loadingMore}
+                style={{
+                  display: 'flex', alignItems: 'center', gap: 6, padding: '8px 20px',
+                  borderRadius: 8, border: '1px solid var(--color-border)',
+                  backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+                  cursor: loadingMore ? 'wait' : 'pointer', fontSize: 13,
+                }}
+              >
+                {loadingMore ? <Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> : <ChevronDown size={14} />}
+                {loadingMore ? 'Loading...' : `Load More (${articles.length} of ${total.toLocaleString()})`}
+              </button>
+            </div>
+          )}
+        </>
+      )}
+    </div>
+  );
+}
diff --git a/components/orbit/OrbitTab.tsx b/components/orbit/OrbitTab.tsx
new file mode 100644
index 0000000..cdf0a31
--- /dev/null
+++ b/components/orbit/OrbitTab.tsx
@@ -0,0 +1,2382 @@
+'use client';
+
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import {
+  Globe, RefreshCw, Loader2, ExternalLink, Copy, Check,
+  TrendingUp, Link2, Rss, BarChart3, Sparkles, X,
+  Send, Mail, ChevronDown, ChevronUp, FileText, Zap,
+  Eye, Target, Clock, AlertCircle, Newspaper,
+  ZoomIn, ZoomOut, Maximize2, Activity, Camera, Film,
+  Gauge, Triangle,
+} from 'lucide-react';
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Types
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface KalshiMarket {
+  id: string;
+  title: string;
+  ticker: string;
+  category: string;
+  yes_price: number;
+  no_price: number;
+  volume: number;
+  status: string;
+  close_time: string | null;
+  // DB fields (optional, used for mapping)
+  yes_bid?: number;
+  yes_ask?: number;
+  no_bid?: number;
+  no_ask?: number;
+  last_price?: number;
+}
+
+interface Petition {
+  id: string;
+  title: string;
+  summary: string;
+  status: 'draft' | 'active' | 'closed';
+  signature_count: number;
+  signature_goal: number;
+  category: string;
+  created_at: string;
+}
+
+interface OrbitLink {
+  id: string;
+  market_id: string;
+  petition_id: string;
+  link_type: 'influence' | 'counter' | 'support' | 'alliance';
+  strength: number;
+  ai_reasoning: string;
+}
+
+interface RSSArticle {
+  id: string;
+  source: string;
+  feed_name?: string;
+  title: string;
+  url: string;
+  published_at: string;
+  sentiment: 'positive' | 'negative' | 'neutral';
+  matched_tickers?: string[] | null;
+  matched_markets?: string[] | null;
+  feed_category: 'national' | 'local' | 'wire';
+}
+
+interface OrbitStats {
+  total_markets: number;
+  linked_petitions: number;
+  active_feeds: number;
+  articles_today: number;
+  avg_sentiment: number;
+}
+
+interface TrendingTopic {
+  id: string;
+  title: string;
+  content?: string;
+  category: string;
+  tags?: string[];
+  engagement_score: number;
+  source_url?: string;
+  created_at: string;
+}
+
+interface AgentInfo {
+  name: string;
+  role: string;
+  port: number;
+  status: 'online' | 'offline' | 'error';
+  uptime?: number;
+}
+
+interface GeneratedPetition {
+  title: string;
+  summary: string;
+  body_html: string;
+  body_text: string;
+  suggested_target: string;
+  suggested_tags: string[];
+  suggested_goal: number;
+  // Alliance fields (optional — present when generated via alliance endpoint)
+  alliance_insight?: string;
+  group_a_appeal?: string;
+  group_b_appeal?: string;
+  impact_multiplier?: string;
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Helpers
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+function decodeEntities(str: string): string {
+  return str
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"')
+    .replace(/&#0?39;/g, "'")
+    .replace(/&apos;/g, "'");
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Constants
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+const CATEGORY_COLORS: Record<string, string> = {
+  Politics: '#3b82f6',
+  Economics: '#22c55e',
+  Climate: '#f97316',
+  Science: '#8b5cf6',
+  Sports: '#ef4444',
+  World: '#06b6d4',
+  Entertainment: '#ec4899',
+  default: '#71717a',
+};
+
+const TRENDING_COLOR = '#10b981'; // emerald-500
+
+const LINK_COLORS: Record<string, string> = {
+  influence: '#7c3aed',
+  counter: '#ef4444',
+  support: '#22c55e',
+  alliance: '#f59e0b',
+};
+
+const SOURCE_COLORS: Record<string, string> = {
+  'CNN': '#cc0000',
+  'Fox News': '#003366',
+  'NBC': '#6a5acd',
+  'Reuters': '#ff8c00',
+  'AP': '#ff0000',
+  'NPR': '#2b9cd8',
+  'BBC': '#bb1919',
+  'Politico': '#ff3333',
+  'The Hill': '#1a5276',
+  'Washington Post': '#231f20',
+  default: '#71717a',
+};
+
+const PLATFORMS: Record<string, { name: string; url: string; color: string }> = {
+  moveon: { name: 'MoveOn', url: 'https://sign.moveon.org/petitions/new', color: '#e53e3e' },
+  change_org: { name: 'Change.org', url: 'https://www.change.org/start-a-petition', color: '#e53e3e' },
+  we_the_people: { name: 'We The People', url: 'https://petitions.whitehouse.gov', color: '#1d4ed8' },
+};
+
+const STATUS_STYLES: Record<string, { bg: string; text: string; border: string }> = {
+  draft: { bg: 'rgba(161,161,170,0.15)', text: '#a1a1aa', border: 'rgba(161,161,170,0.3)' },
+  active: { bg: 'rgba(34,197,94,0.15)', text: '#22c55e', border: 'rgba(34,197,94,0.3)' },
+  closed: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444', border: 'rgba(239,68,68,0.3)' },
+};
+
+/* ─── Mock data for graceful display when API not ready ──────────────── */
+const MOCK_MARKETS: KalshiMarket[] = [
+  { id: 'm1', title: 'Will Congress pass student debt relief by Q3?', ticker: 'STUDEBT-Q3', category: 'Politics', yes_price: 0.34, no_price: 0.66, volume: 245000, status: 'open', close_time: '2026-09-30T23:59:00Z' },
+  { id: 'm2', title: 'Fed rate cut before July 2026?', ticker: 'FEDCUT-JUL26', category: 'Economics', yes_price: 0.58, no_price: 0.42, volume: 892000, status: 'open', close_time: '2026-07-01T23:59:00Z' },
+  { id: 'm3', title: 'US rejoins Paris Climate Agreement by 2027?', ticker: 'PARIS-27', category: 'Climate', yes_price: 0.22, no_price: 0.78, volume: 167000, status: 'open', close_time: '2027-01-01T23:59:00Z' },
+  { id: 'm4', title: 'Supreme Court rules on social media regulation?', ticker: 'SCOTUS-SM', category: 'Politics', yes_price: 0.71, no_price: 0.29, volume: 534000, status: 'open', close_time: '2026-06-30T23:59:00Z' },
+  { id: 'm5', title: 'Unemployment stays below 5% through 2026?', ticker: 'UNEMP-5PCT', category: 'Economics', yes_price: 0.62, no_price: 0.38, volume: 312000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
+  { id: 'm6', title: 'AI regulation bill passes Senate?', ticker: 'AIREG-SEN', category: 'Science', yes_price: 0.45, no_price: 0.55, volume: 423000, status: 'open', close_time: '2026-12-31T23:59:00Z' },
+];
+
+const MOCK_PETITIONS: Petition[] = [
+  { id: 'p1', title: 'Cancel Student Loan Interest', summary: 'Demand Congress eliminate interest on federal student loans', status: 'active', signature_count: 12450, signature_goal: 25000, category: 'debt_relief', created_at: '2026-02-15T10:00:00Z' },
+  { id: 'p2', title: 'Protect Social Media Free Speech', summary: 'Oppose government overreach in social media regulation', status: 'active', signature_count: 8320, signature_goal: 15000, category: 'policy', created_at: '2026-02-10T08:00:00Z' },
+  { id: 'p3', title: 'Support AI Worker Protections', summary: 'Require AI impact assessments for workforce changes', status: 'draft', signature_count: 0, signature_goal: 10000, category: 'policy', created_at: '2026-02-20T14:00:00Z' },
+  { id: 'p4', title: 'Restore Climate Commitments', summary: 'Push for renewed Paris Agreement participation', status: 'active', signature_count: 34210, signature_goal: 50000, category: 'policy', created_at: '2026-01-28T09:00:00Z' },
+  { id: 'p5', title: 'Lower Prescription Drug Costs', summary: 'Support Medicare negotiation for all drug prices', status: 'active', signature_count: 19800, signature_goal: 30000, category: 'consumer_protection', created_at: '2026-02-01T11:00:00Z' },
+];
+
+const MOCK_LINKS: OrbitLink[] = [
+  { id: 'l1', market_id: 'm1', petition_id: 'p1', link_type: 'influence', strength: 0.85, ai_reasoning: 'Market probability directly tracks legislative momentum on student debt' },
+  { id: 'l2', market_id: 'm4', petition_id: 'p2', link_type: 'counter', strength: 0.72, ai_reasoning: 'SCOTUS ruling could override petition goals on social media regulation' },
+  { id: 'l3', market_id: 'm6', petition_id: 'p3', link_type: 'support', strength: 0.68, ai_reasoning: 'AI regulation bill aligns with worker protection petition demands' },
+  { id: 'l4', market_id: 'm3', petition_id: 'p4', link_type: 'influence', strength: 0.91, ai_reasoning: 'Paris Agreement market directly predicts petition outcome likelihood' },
+  { id: 'l5', market_id: 'm2', petition_id: 'p5', link_type: 'support', strength: 0.45, ai_reasoning: 'Fed policy indirectly affects healthcare cost dynamics' },
+];
+
+const MOCK_ARTICLES: RSSArticle[] = [
+  { id: 'a1', source: 'CNN', title: 'Student debt relief bill gains bipartisan support in Senate committee', url: '#', published_at: new Date(Date.now() - 2 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['STUDEBT-Q3'], feed_category: 'national' },
+  { id: 'a2', source: 'Reuters', title: 'Federal Reserve signals potential rate adjustment in upcoming meeting', url: '#', published_at: new Date(Date.now() - 4 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['FEDCUT-JUL26'], feed_category: 'wire' },
+  { id: 'a3', source: 'Politico', title: 'Supreme Court to hear oral arguments on social media liability case', url: '#', published_at: new Date(Date.now() - 5 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['SCOTUS-SM'], feed_category: 'national' },
+  { id: 'a4', source: 'NPR', title: 'Climate activists renew push for international cooperation on emissions', url: '#', published_at: new Date(Date.now() - 7 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['PARIS-27'], feed_category: 'national' },
+  { id: 'a5', source: 'AP', title: 'Tech companies lobby against proposed AI oversight legislation', url: '#', published_at: new Date(Date.now() - 8 * 3600000).toISOString(), sentiment: 'negative', matched_tickers: ['AIREG-SEN'], feed_category: 'wire' },
+  { id: 'a6', source: 'The Hill', title: 'Prescription drug pricing reform advances through committee markup', url: '#', published_at: new Date(Date.now() - 10 * 3600000).toISOString(), sentiment: 'positive', matched_tickers: ['UNEMP-5PCT'], feed_category: 'national' },
+  { id: 'a7', source: 'BBC', title: 'Global unemployment trends show mixed signals across economies', url: '#', published_at: new Date(Date.now() - 12 * 3600000).toISOString(), sentiment: 'neutral', matched_tickers: ['UNEMP-5PCT'], feed_category: 'wire' },
+];
+
+const MOCK_STATS: OrbitStats = {
+  total_markets: 6,
+  linked_petitions: 5,
+  active_feeds: 7,
+  articles_today: 23,
+  avg_sentiment: 0.42,
+};
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Helpers
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+function relativeTime(dateStr: string): string {
+  const diff = Date.now() - new Date(dateStr).getTime();
+  const minutes = Math.floor(diff / 60000);
+  if (minutes < 1) return 'just now';
+  if (minutes < 60) return `${minutes}m ago`;
+  const hours = Math.floor(minutes / 60);
+  if (hours < 24) return `${hours}h ago`;
+  const days = Math.floor(hours / 24);
+  return `${days}d ago`;
+}
+
+function truncate(str: string, len: number): string {
+  return str.length > len ? str.slice(0, len) + '...' : str;
+}
+
+function sentimentColor(s: string): string {
+  if (s === 'positive') return '#22c55e';
+  if (s === 'negative') return '#ef4444';
+  return '#71717a';
+}
+
+function sentimentLabel(score: number): { label: string; color: string } {
+  if (score > 0.6) return { label: 'Positive', color: '#22c55e' };
+  if (score > 0.4) return { label: 'Mixed', color: '#f59e0b' };
+  return { label: 'Negative', color: '#ef4444' };
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   SVG Mind Map Sub-Component
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface MindMapProps {
+  markets: KalshiMarket[];
+  petitions: Petition[];
+  links: OrbitLink[];
+  trendingTopics: TrendingTopic[];
+  hoveredMarket: string | null;
+  hoveredPetition: string | null;
+  selectedMarkets: string[];
+  selectedPetition: string | null;
+  onHoverMarket: (id: string | null) => void;
+  onHoverPetition: (id: string | null) => void;
+  onSelectMarket: (id: string | null, shift?: boolean) => void;
+  onSelectPetition: (id: string | null) => void;
+  onSelectTrending: (id: string | null) => void;
+}
+
+function MindMap({
+  markets, petitions, links, trendingTopics,
+  hoveredMarket, hoveredPetition,
+  selectedMarkets, selectedPetition,
+  onHoverMarket, onHoverPetition,
+  onSelectMarket, onSelectPetition, onSelectTrending,
+}: MindMapProps) {
+  const containerRef = useRef<HTMLDivElement>(null);
+  const svgRef = useRef<SVGSVGElement>(null);
+  const [dims, setDims] = useState({ w: 800, h: 600 });
+
+  // Zoom system
+  const [zoom, setZoom] = useState(1);
+  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+  const handleZoomIn = useCallback(() => setZoom(z => Math.min(z + 0.2, 3)), []);
+  const handleZoomOut = useCallback(() => setZoom(z => Math.max(z - 0.2, 0.4)), []);
+  const handleZoomReset = useCallback(() => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }, []);
+
+  // Drag system
+  const [posOverrides, setPosOverrides] = useState<Record<string, { x: number; y: number }>>({});
+  const dragRef = useRef<{
+    id: string; type: 'market' | 'petition' | 'trending';
+    offsetX: number; offsetY: number;
+    startX: number; startY: number;
+    dragged: boolean;
+  } | null>(null);
+  const [isDragging, setIsDragging] = useState(false);
+
+  // Hovered trending topic
+  const [hoveredTrending, setHoveredTrending] = useState<string | null>(null);
+
+  useEffect(() => {
+    const el = containerRef.current;
+    if (!el) return;
+    const ro = new ResizeObserver(entries => {
+      for (const entry of entries) {
+        setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
+      }
+    });
+    ro.observe(el);
+    return () => ro.disconnect();
+  }, []);
+
+  const centerX = dims.w / 2;
+  const centerY = dims.h / 2;
+  const radius = Math.min(dims.w, dims.h) * 0.36;
+
+  // Compute positions with drag overrides
+  const marketPositions = useMemo(() => {
+    return markets.map((m, i) => {
+      const angle = (i / Math.max(markets.length, 1)) * 2 * Math.PI - Math.PI / 2;
+      const baseX = centerX - radius * 0.55 + radius * 0.85 * Math.cos(angle);
+      const baseY = centerY + radius * 0.85 * Math.sin(angle);
+      const override = posOverrides[`m-${m.id}`];
+      return { ...m, x: override?.x ?? baseX, y: override?.y ?? baseY };
+    });
+  }, [markets, centerX, centerY, radius, posOverrides]);
+
+  const petitionPositions = useMemo(() => {
+    return petitions.map((p, i) => {
+      const angle = (i / Math.max(petitions.length, 1)) * 2 * Math.PI - Math.PI / 2;
+      const baseX = centerX + radius * 0.55 + radius * 0.85 * Math.cos(angle);
+      const baseY = centerY + radius * 0.85 * Math.sin(angle);
+      const override = posOverrides[`p-${p.id}`];
+      return { ...p, x: override?.x ?? baseX, y: override?.y ?? baseY };
+    });
+  }, [petitions, centerX, centerY, radius, posOverrides]);
+
+  // Trending topic positions (bottom arc)
+  const trendingPositions = useMemo(() => {
+    return trendingTopics.map((t, i) => {
+      const spread = Math.PI * 0.6; // 108-degree arc at the bottom
+      const startAngle = Math.PI / 2 - spread / 2;
+      const angle = startAngle + (i / Math.max(trendingTopics.length - 1, 1)) * spread;
+      const baseX = centerX + radius * 0.95 * Math.cos(angle);
+      const baseY = centerY + radius * 0.95 * Math.sin(angle);
+      const override = posOverrides[`t-${t.id}`];
+      return { ...t, x: override?.x ?? baseX, y: override?.y ?? baseY };
+    });
+  }, [trendingTopics, centerX, centerY, radius, posOverrides]);
+
+  // SVG drag handlers
+  const handleSvgMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
+    if (!dragRef.current || !svgRef.current) return;
+    const rect = svgRef.current.getBoundingClientRect();
+    const x = e.clientX - rect.left - dragRef.current.offsetX;
+    const y = e.clientY - rect.top - dragRef.current.offsetY;
+    const dx = e.clientX - dragRef.current.startX;
+    const dy = e.clientY - dragRef.current.startY;
+    if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragRef.current.dragged = true;
+    const prefix = dragRef.current.type === 'market' ? 'm' : dragRef.current.type === 'petition' ? 'p' : 't';
+    const key = `${prefix}-${dragRef.current.id}`;
+    setPosOverrides(prev => ({ ...prev, [key]: { x, y } }));
+  }, []);
+
+  const handleSvgMouseUp = useCallback(() => {
+    if (dragRef.current) {
+      if (!dragRef.current.dragged) {
+        const { id, type } = dragRef.current;
+        if (type === 'market') onSelectMarket(id);
+        else if (type === 'petition') onSelectPetition(selectedPetition === id ? null : id);
+        else onSelectTrending(id);
+      }
+      dragRef.current = null;
+      setIsDragging(false);
+    }
+  }, [onSelectMarket, onSelectPetition, onSelectTrending, selectedPetition]);
+
+  const handleNodeMouseDown = useCallback((e: React.MouseEvent, id: string, type: 'market' | 'petition' | 'trending', nodeX: number, nodeY: number) => {
+    e.preventDefault();
+    if (!svgRef.current) return;
+    const rect = svgRef.current.getBoundingClientRect();
+    const svgX = e.clientX - rect.left;
+    const svgY = e.clientY - rect.top;
+    dragRef.current = {
+      id, type,
+      offsetX: svgX - nodeX,
+      offsetY: svgY - nodeY,
+      startX: e.clientX,
+      startY: e.clientY,
+      dragged: false,
+    };
+    setIsDragging(true);
+  }, []);
+
+  // Which nodes are highlighted (supports multi-select)
+  const highlightedMarkets = useMemo(() => {
+    const s = new Set<string>();
+    if (hoveredMarket) s.add(hoveredMarket);
+    selectedMarkets.forEach(id => s.add(id));
+    if (hoveredPetition || selectedPetition) {
+      const pid = hoveredPetition || selectedPetition;
+      links.filter(l => l.petition_id === pid).forEach(l => s.add(l.market_id));
+    }
+    return s;
+  }, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
+
+  const highlightedPetitions = useMemo(() => {
+    const s = new Set<string>();
+    if (hoveredPetition) s.add(hoveredPetition);
+    if (selectedPetition) s.add(selectedPetition);
+    if (hoveredMarket || selectedMarkets.length > 0) {
+      const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
+      mids.forEach(mid => links.filter(l => l.market_id === mid).forEach(l => s.add(l.petition_id)));
+    }
+    return s;
+  }, [hoveredPetition, selectedPetition, hoveredMarket, selectedMarkets, links]);
+
+  const highlightedLinks = useMemo(() => {
+    const s = new Set<string>();
+    const mids = hoveredMarket ? [hoveredMarket] : selectedMarkets;
+    const pid = hoveredPetition || selectedPetition;
+    links.forEach(l => {
+      if (mids.some(mid => l.market_id === mid)) s.add(l.id);
+      if (pid && l.petition_id === pid) s.add(l.id);
+    });
+    return s;
+  }, [hoveredMarket, selectedMarkets, hoveredPetition, selectedPetition, links]);
+
+  const anyHighlight = highlightedMarkets.size > 0 || highlightedPetitions.size > 0;
+
+  // Max volume for sizing
+  const maxVol = Math.max(...markets.map(m => m.volume), 1);
+
+  return (
+    <div ref={containerRef} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
+      <svg
+        ref={svgRef}
+        width={dims.w} height={dims.h}
+        viewBox={`${panOffset.x + (dims.w - dims.w / zoom) / 2} ${panOffset.y + (dims.h - dims.h / zoom) / 2} ${dims.w / zoom} ${dims.h / zoom}`}
+        style={{ display: 'block', cursor: isDragging ? 'grabbing' : 'default' }}
+        onMouseMove={handleSvgMouseMove}
+        onMouseUp={handleSvgMouseUp}
+        onMouseLeave={handleSvgMouseUp}
+        onWheel={(e) => {
+          e.preventDefault();
+          if (e.deltaY < 0) handleZoomIn();
+          else handleZoomOut();
+        }}
+      >
+        <defs>
+          {/* Animated dash pattern */}
+          <style>{`
+            @keyframes orbitDash {
+              to { stroke-dashoffset: -40; }
+            }
+            @keyframes orbitPulse {
+              0%, 100% { r: 22; opacity: 0.7; }
+              50% { r: 26; opacity: 1; }
+            }
+            @keyframes orbitGlow {
+              0%, 100% { opacity: 0.3; }
+              50% { opacity: 0.6; }
+            }
+          `}</style>
+          <radialGradient id="hubGradient" cx="50%" cy="50%" r="50%">
+            <stop offset="0%" stopColor="#a78bfa" />
+            <stop offset="100%" stopColor="#7c3aed" />
+          </radialGradient>
+          <filter id="glow">
+            <feGaussianBlur stdDeviation="3" result="coloredBlur" />
+            <feMerge>
+              <feMergeNode in="coloredBlur" />
+              <feMergeNode in="SourceGraphic" />
+            </feMerge>
+          </filter>
+        </defs>
+
+        {/* Connection lines */}
+        {links.map(link => {
+          const mp = marketPositions.find(m => m.id === link.market_id);
+          const pp = petitionPositions.find(p => p.id === link.petition_id);
+          if (!mp || !pp) return null;
+
+          const isHl = highlightedLinks.has(link.id);
+          const opacity = anyHighlight ? (isHl ? 1 : 0.08) : 0.45 + link.strength * 0.3;
+          const strokeWidth = 1.5 + link.strength * 3;
+          const color = LINK_COLORS[link.link_type] || '#7c3aed';
+
+          // Deterministic curve offset from link ID (no Math.random flickering)
+          const hash = link.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
+          const midX = centerX + ((hash % 40) - 20);
+          const midY = centerY + (((hash * 7) % 40) - 20);
+
+          return (
+            <g key={link.id} filter={isHl ? 'url(#glow)' : undefined}>
+              {/* Glow underlay for visibility on dark bg */}
+              <path
+                d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
+                fill="none"
+                stroke={color}
+                strokeWidth={strokeWidth + 2}
+                opacity={opacity * 0.3}
+                strokeLinecap="round"
+              />
+              {/* Main connection line */}
+              <path
+                d={`M ${mp.x} ${mp.y} Q ${midX} ${midY} ${pp.x} ${pp.y}`}
+                fill="none"
+                stroke={color}
+                strokeWidth={strokeWidth}
+                opacity={opacity}
+                strokeDasharray={isHl ? '8 6' : 'none'}
+                strokeLinecap="round"
+                style={isHl ? { animation: 'orbitDash 1.5s linear infinite' } : {}}
+              />
+              {/* Endpoint dots */}
+              <circle cx={mp.x} cy={mp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
+              <circle cx={pp.x} cy={pp.y} r={isHl ? 3 : 2} fill={color} opacity={opacity * 0.8} />
+              {/* Label on hover */}
+              {isHl && (
+                <text
+                  x={(mp.x + pp.x) / 2}
+                  y={(mp.y + pp.y) / 2 - 10}
+                  textAnchor="middle"
+                  fontSize={9}
+                  fill={color}
+                  opacity={0.9}
+                  fontWeight={600}
+                >
+                  {truncate(link.ai_reasoning, 50)}
+                </text>
+              )}
+            </g>
+          );
+        })}
+
+        {/* Center hub */}
+        <circle
+          cx={centerX}
+          cy={centerY}
+          r={24}
+          fill="url(#hubGradient)"
+          filter="url(#glow)"
+          style={{ animation: 'orbitPulse 3s ease-in-out infinite' }}
+        />
+        <circle
+          cx={centerX}
+          cy={centerY}
+          r={34}
+          fill="none"
+          stroke="#7c3aed"
+          strokeWidth={1}
+          opacity={0.2}
+          style={{ animation: 'orbitGlow 3s ease-in-out infinite' }}
+        />
+        <text
+          x={centerX}
+          y={centerY + 1}
+          textAnchor="middle"
+          dominantBaseline="middle"
+          fontSize={10}
+          fontWeight={800}
+          fill="#fff"
+          letterSpacing="0.1em"
+        >
+          ORBIT
+        </text>
+
+        {/* Market nodes (left arc) */}
+        {marketPositions.map(m => {
+          const catColor = CATEGORY_COLORS[m.category] || CATEGORY_COLORS.default;
+          const nodeR = 14 + (m.volume / maxVol) * 16;
+          const isHl = highlightedMarkets.has(m.id);
+          const isSelected = selectedMarkets.includes(m.id);
+          const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
+
+          return (
+            <g
+              key={m.id}
+              style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+              opacity={opacity}
+              onMouseEnter={() => !isDragging && onHoverMarket(m.id)}
+              onMouseLeave={() => !isDragging && onHoverMarket(null)}
+              onMouseDown={(e) => {
+                // Shift-click handled inside handleSvgMouseUp via onSelectMarket
+                if (e.shiftKey) {
+                  e.preventDefault();
+                  onSelectMarket(m.id, true);
+                  return;
+                }
+                handleNodeMouseDown(e, m.id, 'market', m.x, m.y);
+              }}
+            >
+              {/* Glow ring on selection */}
+              {isSelected && (
+                <circle
+                  cx={m.x} cy={m.y} r={nodeR + 6}
+                  fill="none" stroke={catColor} strokeWidth={2}
+                  opacity={0.5}
+                  strokeDasharray="4 3"
+                  style={{ animation: 'orbitDash 2s linear infinite' }}
+                />
+              )}
+              <circle
+                cx={m.x} cy={m.y} r={nodeR}
+                fill={`${catColor}18`}
+                stroke={catColor}
+                strokeWidth={isHl ? 2.5 : 1.5}
+                filter={isSelected ? 'url(#glow)' : undefined}
+              />
+              {/* Yes probability arc */}
+              <circle
+                cx={m.x} cy={m.y} r={nodeR - 3}
+                fill="none"
+                stroke={catColor}
+                strokeWidth={3}
+                strokeDasharray={`${m.yes_price * 2 * Math.PI * (nodeR - 3)} ${2 * Math.PI * (nodeR - 3)}`}
+                strokeDashoffset={0}
+                transform={`rotate(-90, ${m.x}, ${m.y})`}
+                opacity={0.75}
+              />
+              {/* Probability text */}
+              <text
+                x={m.x} y={m.y - 1}
+                textAnchor="middle" dominantBaseline="middle"
+                fontSize={nodeR > 20 ? 12 : 10} fontWeight={800}
+                fill={catColor}
+              >
+                {Math.round(m.yes_price * 100)}%
+              </text>
+              {/* Title below with background for readability */}
+              <text
+                x={m.x} y={m.y + nodeR + 12}
+                textAnchor="middle"
+                fontSize={9}
+                fill="var(--color-text)"
+                fontWeight={600}
+                style={{ filter: 'drop-shadow(0 0 3px rgba(10,10,18,0.9)) drop-shadow(0 0 6px rgba(10,10,18,0.7))' }}
+              >
+                {truncate(m.title, 22)}
+              </text>
+            </g>
+          );
+        })}
+
+        {/* Petition nodes (right arc) */}
+        {petitionPositions.map(p => {
+          const sty = STATUS_STYLES[p.status] || STATUS_STYLES.draft;
+          const progress = p.signature_goal > 0 ? p.signature_count / p.signature_goal : 0;
+          const isHl = highlightedPetitions.has(p.id);
+          const isSelected = selectedPetition === p.id;
+          const opacity = anyHighlight ? (isHl ? 1 : 0.25) : 0.85;
+          const rectW = 130;
+          const rectH = 44;
+
+          return (
+            <g
+              key={p.id}
+              style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+              opacity={opacity}
+              onMouseEnter={() => !isDragging && onHoverPetition(p.id)}
+              onMouseLeave={() => !isDragging && onHoverPetition(null)}
+              onMouseDown={(e) => handleNodeMouseDown(e, p.id, 'petition', p.x, p.y)}
+            >
+              {/* Selection ring */}
+              {isSelected && (
+                <rect
+                  x={p.x - rectW / 2 - 4} y={p.y - rectH / 2 - 4}
+                  width={rectW + 8} height={rectH + 8}
+                  rx={14} ry={14}
+                  fill="none" stroke="#a78bfa" strokeWidth={2}
+                  opacity={0.5}
+                  strokeDasharray="4 3"
+                  style={{ animation: 'orbitDash 2s linear infinite' }}
+                />
+              )}
+              <rect
+                x={p.x - rectW / 2} y={p.y - rectH / 2}
+                width={rectW} height={rectH}
+                rx={10} ry={10}
+                fill="rgba(30, 30, 50, 0.9)"
+                stroke={isHl ? '#a78bfa' : 'rgba(100, 100, 140, 0.4)'}
+                strokeWidth={isHl ? 2 : 1}
+              />
+              {/* Left accent bar */}
+              <rect
+                x={p.x - rectW / 2 + 1} y={p.y - rectH / 2 + 6}
+                width={3} height={rectH - 12}
+                rx={1.5} ry={1.5}
+                fill={sty.text}
+              />
+              {/* Title */}
+              <text
+                x={p.x + 2} y={p.y - 8}
+                textAnchor="middle" dominantBaseline="middle"
+                fontSize={9} fontWeight={700}
+                fill="var(--color-text)"
+              >
+                {truncate(p.title, 20)}
+              </text>
+              {/* Progress bar */}
+              <rect
+                x={p.x - rectW / 2 + 8} y={p.y + 4}
+                width={rectW - 16} height={4}
+                rx={2} ry={2}
+                fill="var(--color-bg)"
+              />
+              <rect
+                x={p.x - rectW / 2 + 8} y={p.y + 4}
+                width={(rectW - 16) * Math.min(progress, 1)} height={4}
+                rx={2} ry={2}
+                fill={sty.text}
+              />
+              {/* Sig count */}
+              <text
+                x={p.x} y={p.y + 16}
+                textAnchor="middle" dominantBaseline="middle"
+                fontSize={7}
+                fill="var(--color-text-muted)"
+              >
+                {((p.signature_count || 0) / 1000).toFixed(1)}k / {((p.signature_goal || 1) / 1000).toFixed(0)}k
+              </text>
+            </g>
+          );
+        })}
+
+        {/* Trending topic nodes (bottom arc — green diamonds) */}
+        {trendingPositions.map(t => {
+          const maxScore = Math.max(...trendingTopics.map(tt => tt.engagement_score), 1);
+          const size = 10 + (t.engagement_score / maxScore) * 10;
+          const isHovered = hoveredTrending === t.id;
+          const opacity = isHovered ? 1 : 0.8;
+
+          return (
+            <g
+              key={`trending-${t.id}`}
+              style={{ cursor: isDragging ? 'grabbing' : 'grab', transition: 'opacity 150ms ease' }}
+              opacity={opacity}
+              onMouseEnter={() => { if (!isDragging) setHoveredTrending(t.id); }}
+              onMouseLeave={() => { if (!isDragging) setHoveredTrending(null); }}
+              onMouseDown={(e) => handleNodeMouseDown(e, t.id, 'trending', t.x, t.y)}
+            >
+              {/* Diamond shape (rotated square) */}
+              <rect
+                x={t.x - size / 2} y={t.y - size / 2}
+                width={size} height={size}
+                rx={3} ry={3}
+                transform={`rotate(45, ${t.x}, ${t.y})`}
+                fill={`${TRENDING_COLOR}25`}
+                stroke={TRENDING_COLOR}
+                strokeWidth={isHovered ? 2.5 : 1.5}
+              />
+              {/* Engagement score inside */}
+              <text
+                x={t.x} y={t.y + 1}
+                textAnchor="middle" dominantBaseline="middle"
+                fontSize={7} fontWeight={700}
+                fill={TRENDING_COLOR}
+              >
+                {t.engagement_score}
+              </text>
+              {/* Title below — only show on hover to reduce clutter */}
+              {isHovered && (
+                <text
+                  x={t.x} y={t.y + size / 2 + 14}
+                  textAnchor="middle"
+                  fontSize={8}
+                  fill="var(--color-text)"
+                  fontWeight={600}
+                >
+                  {truncate(t.title, 28)}
+                </text>
+              )}
+              {/* Tooltip on hover */}
+              {isHovered && t.content && (
+                <g>
+                  <rect
+                    x={t.x - 90} y={t.y - size / 2 - 32}
+                    width={180} height={22}
+                    rx={6} fill="var(--color-surface-el)"
+                    stroke="var(--color-border)" strokeWidth={0.5}
+                  />
+                  <text
+                    x={t.x} y={t.y - size / 2 - 18}
+                    textAnchor="middle" dominantBaseline="middle"
+                    fontSize={7} fill="var(--color-text-muted)"
+                  >
+                    {truncate(t.content, 50)}
+                  </text>
+                </g>
+              )}
+            </g>
+          );
+        })}
+
+        {/* Alliance line between 2 selected markets */}
+        {selectedMarkets.length === 2 && (() => {
+          const m1 = marketPositions.find(m => m.id === selectedMarkets[0]);
+          const m2 = marketPositions.find(m => m.id === selectedMarkets[1]);
+          if (!m1 || !m2) return null;
+          const midX = (m1.x + m2.x) / 2;
+          const midY = (m1.y + m2.y) / 2;
+          return (
+            <g>
+              <line
+                x1={m1.x} y1={m1.y} x2={m2.x} y2={m2.y}
+                stroke="#f59e0b" strokeWidth={2.5}
+                strokeDasharray="6 4" opacity={0.8}
+                style={{ animation: 'orbitDash 1.5s linear infinite' }}
+              />
+              <rect x={midX - 36} y={midY - 10} width={72} height={18} rx={9} fill="#f59e0b" opacity={0.9} />
+              <text x={midX} y={midY + 2} textAnchor="middle" dominantBaseline="middle"
+                fontSize={8} fontWeight={700} fill="#000" letterSpacing="0.03em">
+                ALLIANCE
+              </text>
+            </g>
+          );
+        })()}
+
+        {/* Legend */}
+        <g transform={`translate(12, ${dims.h - 80})`}>
+          <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">LINK TYPES</text>
+          {Object.entries(LINK_COLORS).map(([type, color], i) => (
+            <g key={type} transform={`translate(0, ${16 + i * 15})`}>
+              <line x1={0} y1={0} x2={18} y2={0} stroke={color} strokeWidth={2.5} />
+              <text x={22} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{type.charAt(0).toUpperCase() + type.slice(1)}</text>
+            </g>
+          ))}
+        </g>
+
+        {/* Category legend */}
+        <g transform={`translate(12, 12)`}>
+          <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">CATEGORIES</text>
+          {Object.entries(CATEGORY_COLORS).filter(([k]) => k !== 'default').slice(0, 5).map(([cat, color], i) => (
+            <g key={cat} transform={`translate(${i * 68}, 16)`}>
+              <circle cx={5} cy={0} r={4} fill={color} />
+              <text x={12} y={3} fontSize={8} fill="var(--color-text-muted)" fontWeight={500}>{cat}</text>
+            </g>
+          ))}
+        </g>
+
+        {/* Node type legend (top right) */}
+        <g transform={`translate(${dims.w - 130}, 12)`}>
+          <text fontSize={9} fill="var(--color-text-secondary)" fontWeight={700} letterSpacing="0.05em">NODES</text>
+          <g transform="translate(0, 14)">
+            <circle cx={5} cy={0} r={4} fill="#3b82f622" stroke="#3b82f6" strokeWidth={1} />
+            <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Market (circle)</text>
+          </g>
+          <g transform="translate(0, 28)">
+            <rect x={1} y={-4} width={8} height={8} rx={2} fill="var(--color-surface-el)" stroke="var(--color-border)" strokeWidth={1} />
+            <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Petition (rect)</text>
+          </g>
+          <g transform="translate(0, 42)">
+            <rect x={2} y={-3} width={6} height={6} rx={1} fill={`${TRENDING_COLOR}25`} stroke={TRENDING_COLOR} strokeWidth={1}
+              transform="rotate(45, 5, 0)" />
+            <text x={14} y={3} fontSize={7} fill="var(--color-text-muted)">Trending (diamond)</text>
+          </g>
+        </g>
+
+        {/* Shift+click hint */}
+        <text x={dims.w - 12} y={dims.h - 8} textAnchor="end"
+          fontSize={8} fill="var(--color-text-muted)" opacity={0.5}>
+          Drag to move | Scroll to zoom | Shift+click for Alliance
+        </text>
+      </svg>
+
+      {/* Zoom controls (top-left overlay) */}
+      <div style={{
+        position: 'absolute', top: 8, left: 8, zIndex: 10,
+        display: 'flex', flexDirection: 'column', gap: 2,
+      }}>
+        {[
+          { icon: <ZoomIn size={13} />, action: handleZoomIn, title: 'Zoom In' },
+          { icon: <ZoomOut size={13} />, action: handleZoomOut, title: 'Zoom Out' },
+          { icon: <Maximize2 size={13} />, action: handleZoomReset, title: 'Reset View' },
+        ].map(({ icon, action, title }) => (
+          <button
+            key={title}
+            onClick={action}
+            title={title}
+            style={{
+              width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
+              borderRadius: 6, border: '1px solid var(--color-border)',
+              backgroundColor: 'var(--color-surface-el)',
+              color: 'var(--color-text-muted)', cursor: 'pointer',
+              transition: 'all 150ms ease',
+            }}
+            onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
+            onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
+          >
+            {icon}
+          </button>
+        ))}
+      </div>
+
+      {/* Zoom level indicator */}
+      {zoom !== 1 && (
+        <div style={{
+          position: 'absolute', top: 8, left: 42,
+          padding: '4px 8px', borderRadius: 6,
+          backgroundColor: 'var(--color-surface-el)',
+          border: '1px solid var(--color-border)',
+          fontSize: 9, fontWeight: 600, color: '#a78bfa',
+        }}>
+          {Math.round(zoom * 100)}%
+        </div>
+      )}
+
+      {/* Reset Layout button */}
+      {Object.keys(posOverrides).length > 0 && (
+        <button
+          onClick={() => setPosOverrides({})}
+          style={{
+            position: 'absolute', bottom: 8, left: 12,
+            padding: '3px 10px', borderRadius: 6,
+            border: '1px solid var(--color-border)',
+            backgroundColor: 'var(--color-surface-el)',
+            color: 'var(--color-text-muted)',
+            fontSize: 9, fontWeight: 600, cursor: 'pointer',
+            zIndex: 10, transition: 'all 150ms ease',
+          }}
+          onMouseEnter={e => { e.currentTarget.style.borderColor = '#a78bfa'; e.currentTarget.style.color = '#a78bfa'; }}
+          onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.color = 'var(--color-text-muted)'; }}
+        >
+          Reset Layout
+        </button>
+      )}
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Pulse Panel Sub-Component
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface PulsePanelProps {
+  articles: RSSArticle[];
+  loading: boolean;
+  onRefresh: () => void;
+  refreshing: boolean;
+}
+
+function PulsePanel({ articles, loading, onRefresh, refreshing }: PulsePanelProps) {
+  const [feedFilter, setFeedFilter] = useState<string>('all');
+  const filters = ['all', 'national', 'local', 'wire'];
+
+  const filtered = useMemo(() => {
+    if (feedFilter === 'all') return articles;
+    return articles.filter(a => a.feed_category === feedFilter);
+  }, [articles, feedFilter]);
+
+  return (
+    <div style={{
+      display: 'flex', flexDirection: 'column', height: '100%',
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      borderRadius: 12, overflow: 'hidden',
+    }}>
+      {/* Header */}
+      <div style={{
+        padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+        borderBottom: '1px solid var(--color-border)', flexShrink: 0,
+      }}>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+          <Rss size={14} style={{ color: '#a78bfa' }} />
+          <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Pulse Feed</span>
+          <span style={{
+            fontSize: 9, padding: '1px 6px', borderRadius: 99,
+            backgroundColor: 'rgba(124, 58, 237, 0.15)', color: '#a78bfa',
+            fontWeight: 600,
+          }}>
+            {articles.length}
+          </span>
+        </div>
+        <button
+          onClick={onRefresh}
+          disabled={refreshing}
+          style={{
+            display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px',
+            borderRadius: 6, border: '1px solid var(--color-border)',
+            backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+            cursor: 'pointer', fontSize: 10, fontWeight: 600,
+            transition: 'all 150ms ease',
+          }}
+          onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+          onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+        >
+          {refreshing
+            ? <Loader2 size={11} style={{ animation: 'spin 1s linear infinite' }} />
+            : <RefreshCw size={11} />
+          }
+          Refresh
+        </button>
+      </div>
+
+      {/* Filter chips */}
+      <div style={{
+        padding: '8px 14px', display: 'flex', gap: 4, flexShrink: 0,
+        borderBottom: '1px solid var(--color-border)',
+      }}>
+        {filters.map(f => (
+          <button
+            key={f}
+            onClick={() => setFeedFilter(f)}
+            style={{
+              padding: '3px 10px', borderRadius: 99, border: 'none',
+              fontSize: 10, fontWeight: 600, cursor: 'pointer',
+              backgroundColor: feedFilter === f ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
+              color: feedFilter === f ? '#a78bfa' : 'var(--color-text-muted)',
+              transition: 'all 150ms ease',
+              textTransform: 'capitalize',
+            }}
+          >
+            {f}
+          </button>
+        ))}
+      </div>
+
+      {/* Articles list */}
+      <div style={{ flex: 1, overflowY: 'auto', padding: '6px 10px' }}>
+        {loading && (
+          <div style={{ padding: 20, textAlign: 'center' }}>
+            <Loader2 size={18} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />
+          </div>
+        )}
+        {!loading && filtered.length === 0 && (
+          <div style={{ padding: 20, textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 11 }}>
+            No articles found for this filter.
+          </div>
+        )}
+        {!loading && filtered.map(article => {
+          const srcName = article.source || article.feed_name || 'Unknown';
+          const srcColor = SOURCE_COLORS[srcName] || SOURCE_COLORS.default;
+          const sentColor = sentimentColor(article.sentiment || 'neutral');
+          return (
+            <div
+              key={article.id}
+              style={{
+                padding: '8px 10px 8px 12px', borderRadius: 8, marginBottom: 4,
+                borderLeft: `2px solid ${srcColor}40`,
+                transition: 'all 150ms ease', cursor: 'default',
+              }}
+              onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+              onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+            >
+              <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
+                {/* Source badge */}
+                <span style={{
+                  fontSize: 8, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
+                  backgroundColor: `${srcColor}20`, color: srcColor,
+                  border: `1px solid ${srcColor}40`,
+                  textTransform: 'uppercase', letterSpacing: '0.03em',
+                }}>
+                  {srcName}
+                </span>
+                {/* Time */}
+                <span style={{ fontSize: 9, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
+                  {relativeTime(article.published_at)}
+                </span>
+              </div>
+              {/* Title */}
+              <a
+                href={article.url}
+                target="_blank"
+                rel="noopener noreferrer"
+                style={{
+                  fontSize: 11, fontWeight: 600, color: 'var(--color-text)',
+                  textDecoration: 'none', lineHeight: 1.35,
+                  display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
+                  overflow: 'hidden',
+                }}
+              >
+                {decodeEntities(article.title)}
+              </a>
+              {/* Bottom row: sentiment (only if non-neutral) + tickers */}
+              <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
+                {article.sentiment && article.sentiment !== 'neutral' && (
+                  <span style={{
+                    fontSize: 8, fontWeight: 600, padding: '1px 5px', borderRadius: 4,
+                    backgroundColor: `${sentColor}20`, color: sentColor,
+                    textTransform: 'capitalize',
+                  }}>
+                    {article.sentiment}
+                  </span>
+                )}
+                {(article.matched_tickers || article.matched_markets || []).map(t => (
+                  <span key={t} style={{
+                    fontSize: 8, fontWeight: 700, padding: '2px 6px', borderRadius: 4,
+                    backgroundColor: 'rgba(124, 58, 237, 0.12)', color: '#a78bfa',
+                    letterSpacing: '0.02em',
+                  }}>
+                    {t}
+                  </span>
+                ))}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Actions Panel Sub-Component
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface ActionsPanelProps {
+  selectedMarkets: KalshiMarket[];
+  selectedPetition: Petition | null;
+  onClearSelection: () => void;
+  onRefresh: () => void;
+}
+
+function ActionsPanel({ selectedMarkets, selectedPetition, onClearSelection, onRefresh }: ActionsPanelProps) {
+  const [tone, setTone] = useState('urgent');
+  const [generating, setGenerating] = useState(false);
+  const [generated, setGenerated] = useState<GeneratedPetition | null>(null);
+  const [copiedPlatform, setCopiedPlatform] = useState<string | null>(null);
+  const [actionsExpanded, setActionsExpanded] = useState(true);
+  const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' | 'info' } | null>(null);
+  const [saving, setSaving] = useState(false);
+  const [batchLinking, setBatchLinking] = useState(false);
+
+  function showToast(msg: string, type: 'success' | 'error' | 'info' = 'info') {
+    setToast({ msg, type });
+    setTimeout(() => setToast(null), 3000);
+  }
+
+  // Derived: first selected market (for single-market mode)
+  const selectedMarket = selectedMarkets[0] || null;
+  // Alliance mode: 2 markets from different categories
+  const isAllianceMode = selectedMarkets.length === 2 &&
+    selectedMarkets[0].category !== selectedMarkets[1].category;
+
+  async function handleGenerate() {
+    if (!selectedMarket) return;
+    setGenerating(true);
+    setGenerated(null);
+    try {
+      const res = await fetch('/api/orbit/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          market_id: selectedMarket.id,
+          market_title: selectedMarket.title,
+          market_ticker: selectedMarket.ticker,
+          market_yes_price: selectedMarket.yes_price,
+          tone,
+        }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setGenerated(data.petition || data);
+      } else {
+        setGenerated({
+          title: `Action Needed: ${selectedMarket.title}`,
+          summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action on this critical issue.`,
+          body_html: `<p>We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.</p>`,
+          body_text: `We, the undersigned, call for immediate attention to the issue tracked by prediction market ${selectedMarket.ticker}.`,
+          suggested_target: 'U.S. Congress',
+          suggested_tags: [(selectedMarket.category || 'Other').toLowerCase(), 'prediction-market', 'action'],
+          suggested_goal: 10000,
+        });
+      }
+    } catch {
+      setGenerated({
+        title: `Action Needed: ${selectedMarket.title}`,
+        summary: `Based on Kalshi market ${selectedMarket.ticker} showing ${Math.round(selectedMarket.yes_price * 100)}% probability, we urge immediate action.`,
+        body_html: `<p>We call for immediate attention to this issue.</p>`,
+        body_text: `We call for immediate attention to this issue.`,
+        suggested_target: 'U.S. Congress',
+        suggested_tags: [(selectedMarket.category || 'Other').toLowerCase()],
+        suggested_goal: 10000,
+      });
+    } finally {
+      setGenerating(false);
+    }
+  }
+
+  async function handleAllianceGenerate() {
+    if (selectedMarkets.length !== 2) return;
+    setGenerating(true);
+    setGenerated(null);
+    try {
+      const res = await fetch('/api/orbit/suggest-alliance', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          market_tickers: [selectedMarkets[0].ticker, selectedMarkets[1].ticker],
+          tone,
+        }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setGenerated(data.petition || data);
+      } else {
+        // Fallback alliance mock
+        setGenerated({
+          title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
+          summary: `An unlikely alliance petition bridging ${selectedMarkets[0].title} and ${selectedMarkets[1].title} for maximum political impact.`,
+          body_html: `<p>We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.</p>`,
+          body_text: `We, a coalition spanning ${selectedMarkets[0].category} and ${selectedMarkets[1].category} advocates, demand unified action.`,
+          suggested_target: 'U.S. Congress',
+          suggested_tags: [selectedMarkets[0].category.toLowerCase(), selectedMarkets[1].category.toLowerCase(), 'alliance'],
+          suggested_goal: 25000,
+          alliance_insight: `This alliance between ${selectedMarkets[0].category} and ${selectedMarkets[1].category} groups creates cross-partisan momentum that neither group could achieve alone.`,
+          group_a_appeal: `${selectedMarkets[0].category} supporters care because it directly impacts ${selectedMarkets[0].title}.`,
+          group_b_appeal: `${selectedMarkets[1].category} supporters care because it connects to ${selectedMarkets[1].title}.`,
+          impact_multiplier: `Cross-partisan petitions historically achieve 3-5x higher engagement and are more likely to receive legislative attention.`,
+        });
+      }
+    } catch {
+      setGenerated({
+        title: `United Action: ${selectedMarkets[0].category} + ${selectedMarkets[1].category}`,
+        summary: `Alliance petition bridging two issue groups.`,
+        body_html: `<p>Coalition petition spanning multiple constituencies.</p>`,
+        body_text: `Coalition petition spanning multiple constituencies.`,
+        suggested_target: 'U.S. Congress',
+        suggested_tags: ['alliance'],
+        suggested_goal: 25000,
+      });
+    } finally {
+      setGenerating(false);
+    }
+  }
+
+  function handleCopyAndOpen(platformKey: string) {
+    const platform = PLATFORMS[platformKey];
+    if (!platform || !generated) return;
+    const text = `${generated.title}\n\n${generated.body_text || generated.summary}`;
+    // Clipboard with fallback for HTTP contexts
+    const doCopy = () => {
+      if (navigator.clipboard?.writeText) {
+        return navigator.clipboard.writeText(text);
+      }
+      // Fallback: textarea copy
+      const ta = document.createElement('textarea');
+      ta.value = text;
+      ta.style.position = 'fixed';
+      ta.style.left = '-9999px';
+      document.body.appendChild(ta);
+      ta.select();
+      document.execCommand('copy');
+      document.body.removeChild(ta);
+      return Promise.resolve();
+    };
+    doCopy().then(() => {
+      setCopiedPlatform(platformKey);
+      showToast(`Copied to clipboard! Opening ${platform.name}...`, 'success');
+      setTimeout(() => setCopiedPlatform(null), 2000);
+      window.open(platform.url, '_blank');
+    }).catch(() => {
+      showToast('Copy failed — opening platform anyway', 'error');
+      window.open(platform.url, '_blank');
+    });
+  }
+
+  async function handleSaveDraft() {
+    if (!generated) return;
+    setSaving(true);
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: generated.title,
+          summary: generated.summary,
+          body_html: generated.body_html || `<p>${generated.summary}</p>`,
+          body_text: generated.body_text || generated.summary,
+          target: generated.suggested_target,
+          tags: generated.suggested_tags,
+          signature_goal: generated.suggested_goal || 10000,
+          status: 'draft',
+        }),
+      });
+      if (res.ok) {
+        showToast('Petition saved as draft!', 'success');
+        setGenerated(null);
+        onClearSelection();
+        onRefresh();
+      } else {
+        const err = await res.json().catch(() => ({ error: 'Save failed' }));
+        showToast(err.error || 'Failed to save petition', 'error');
+      }
+    } catch {
+      showToast('Network error — petition not saved', 'error');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  async function handleBatchLink() {
+    setBatchLinking(true);
+    try {
+      const res = await fetch('/api/orbit/batch-link', { method: 'POST' });
+      if (res.ok) {
+        const data = await res.json();
+        showToast(`AI linked ${data.links_inserted} connections, created ${data.petitions_created} petitions`, 'success');
+      } else {
+        showToast('AI linking failed — try again', 'error');
+      }
+    } catch {
+      showToast('Network error during AI linking', 'error');
+    } finally {
+      setBatchLinking(false);
+    }
+  }
+
+  async function handleActivatePetition(petitionId: string) {
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ id: petitionId, status: 'active' }),
+      });
+      if (res.ok) { showToast('Petition activated!', 'success'); onRefresh(); }
+      else showToast('Failed to activate', 'error');
+    } catch {
+      showToast('Network error', 'error');
+    }
+  }
+
+  async function handleArchivePetition(petitionId: string) {
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ id: petitionId, status: 'closed' }),
+      });
+      if (res.ok) {
+        showToast('Petition archived', 'info');
+        onClearSelection();
+        onRefresh();
+      } else showToast('Failed to archive', 'error');
+    } catch {
+      showToast('Network error', 'error');
+    }
+  }
+
+  return (
+    <div style={{
+      display: 'flex', flexDirection: 'column', height: '100%',
+      backgroundColor: 'var(--color-surface)',
+      border: '1px solid var(--color-border)',
+      borderRadius: 12, overflow: 'hidden',
+      position: 'relative',
+    }}>
+      {/* Header */}
+      <div
+        style={{
+          padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+          borderBottom: '1px solid var(--color-border)', flexShrink: 0, cursor: 'pointer',
+        }}
+        onClick={() => setActionsExpanded(!actionsExpanded)}
+      >
+        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+          <Zap size={14} style={{ color: '#a78bfa' }} />
+          <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)' }}>Actions</span>
+        </div>
+        {actionsExpanded ? <ChevronUp size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
+      </div>
+
+      {/* Toast notification */}
+      {toast && (
+        <div style={{
+          position: 'absolute', bottom: 12, left: '50%', transform: 'translateX(-50%)',
+          zIndex: 100, padding: '8px 16px', borderRadius: 8,
+          backgroundColor: toast.type === 'success' ? 'rgba(34, 197, 94, 0.9)' : toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'rgba(124, 58, 237, 0.9)',
+          color: '#fff', fontSize: 11, fontWeight: 600, whiteSpace: 'nowrap',
+          boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
+          animation: 'fadeInUp 200ms ease',
+        }}>
+          {toast.msg}
+        </div>
+      )}
+
+      {actionsExpanded && (
+        <div style={{ flex: 1, overflowY: 'auto', padding: '10px 14px' }}>
+          {/* No selection — Quick Actions */}
+          {selectedMarkets.length === 0 && !selectedPetition && !generated && (
+            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+              <div style={{ textAlign: 'center', padding: '12px 10px' }}>
+                <div style={{
+                  width: 40, height: 40, borderRadius: 12, margin: '0 auto 10px',
+                  display: 'flex', alignItems: 'center', justifyContent: 'center',
+                  background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+                  border: '1px solid rgba(124, 58, 237, 0.2)',
+                }}>
+                  <Target size={18} style={{ color: '#a78bfa' }} />
+                </div>
+                <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
+                  Select a Node
+                </div>
+                <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
+                  Click a market to generate a petition. Shift+click two from different categories for Alliance mode.
+                </div>
+              </div>
+
+              {/* Quick Action Buttons */}
+              <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+                <div style={{ fontSize: 9, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>
+                  Quick Actions
+                </div>
+                <button
+                  onClick={handleBatchLink}
+                  disabled={batchLinking}
+                  style={{
+                    display: 'flex', alignItems: 'center', gap: 8,
+                    padding: '8px 10px', borderRadius: 8,
+                    border: '1px solid rgba(167, 139, 250, 0.3)',
+                    backgroundColor: 'rgba(167, 139, 250, 0.06)',
+                    color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                    transition: 'all 150ms ease', textAlign: 'left',
+                    opacity: batchLinking ? 0.7 : 1,
+                  }}
+                  onMouseEnter={e => { if (!batchLinking) e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.12)'; }}
+                  onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(167, 139, 250, 0.06)'; }}
+                >
+                  {batchLinking
+                    ? <Loader2 size={13} style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }} />
+                    : <Sparkles size={13} style={{ flexShrink: 0 }} />}
+                  <span style={{ flex: 1 }}>AI Auto-Link Markets & Petitions</span>
+                </button>
+                <button
+                  onClick={async () => {
+                    showToast('Discovering trending topics...', 'info');
+                    try {
+                      const res = await fetch('/api/trending/discover', { method: 'POST' });
+                      if (res.ok) {
+                        const data = await res.json();
+                        showToast(`Discovered ${data.count || 0} trending topics!`, 'success');
+                      } else showToast('Discovery failed', 'error');
+                    } catch { showToast('Network error', 'error'); }
+                  }}
+                  style={{
+                    display: 'flex', alignItems: 'center', gap: 8,
+                    padding: '8px 10px', borderRadius: 8,
+                    border: '1px solid rgba(34, 197, 94, 0.3)',
+                    backgroundColor: 'rgba(34, 197, 94, 0.06)',
+                    color: '#22c55e', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                    transition: 'all 150ms ease', textAlign: 'left',
+                  }}
+                  onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.12)'; }}
+                  onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(34, 197, 94, 0.06)'; }}
+                >
+                  <TrendingUp size={13} style={{ flexShrink: 0 }} />
+                  <span style={{ flex: 1 }}>Discover Trending Topics</span>
+                </button>
+                <button
+                  onClick={() => {
+                    showToast('Opening Pulse Agent...', 'info');
+                    window.open('http://45.61.58.125:9845/', '_blank');
+                  }}
+                  style={{
+                    display: 'flex', alignItems: 'center', gap: 8,
+                    padding: '8px 10px', borderRadius: 8,
+                    border: '1px solid rgba(6, 182, 212, 0.3)',
+                    backgroundColor: 'rgba(6, 182, 212, 0.06)',
+                    color: '#06b6d4', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                    transition: 'all 150ms ease', textAlign: 'left',
+                  }}
+                  onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.12)'; }}
+                  onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(6, 182, 212, 0.06)'; }}
+                >
+                  <Globe size={13} style={{ flexShrink: 0 }} />
+                  <span style={{ flex: 1 }}>Open Pulse of America</span>
+                </button>
+              </div>
+            </div>
+          )}
+
+          {/* Alliance mode — 2 markets from different categories */}
+          {isAllianceMode && !generated && (
+            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+              {/* Alliance badge */}
+              <div style={{
+                display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                padding: '6px 12px', borderRadius: 8,
+                background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(245, 158, 11, 0.05))',
+                border: '1px solid rgba(245, 158, 11, 0.3)',
+              }}>
+                <Zap size={12} style={{ color: '#f59e0b' }} />
+                <span style={{ fontSize: 10, fontWeight: 700, color: '#f59e0b' }}>
+                  Alliance Detected: {selectedMarkets[0].category} + {selectedMarkets[1].category}
+                </span>
+              </div>
+
+              {/* Both markets stacked */}
+              {selectedMarkets.map((m, idx) => (
+                <div key={m.id} style={{
+                  padding: '8px 12px', borderRadius: 8,
+                  backgroundColor: 'var(--color-surface-el)',
+                  border: `1px solid ${CATEGORY_COLORS[m.category] || 'var(--color-border)'}33`,
+                }}>
+                  <div style={{ fontSize: 9, fontWeight: 600, color: CATEGORY_COLORS[m.category] || '#71717a', marginBottom: 2, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                    Group {String.fromCharCode(65 + idx)}: {m.category}
+                  </div>
+                  <div style={{ fontSize: 10, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.3 }}>
+                    {m.title}
+                  </div>
+                  <div style={{ fontSize: 9, color: '#22c55e', fontWeight: 600, marginTop: 2 }}>
+                    {Math.round((m.yes_price || 0) * 100)}% probability
+                  </div>
+                </div>
+              ))}
+
+              {/* Tone selector */}
+              <div>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
+                <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+                  {['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
+                    <button key={t} onClick={() => setTone(t)}
+                      style={{
+                        padding: '3px 10px', borderRadius: 99, border: 'none',
+                        fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                        backgroundColor: tone === t ? 'rgba(245, 158, 11, 0.2)' : 'var(--color-surface-el)',
+                        color: tone === t ? '#f59e0b' : 'var(--color-text-muted)',
+                        textTransform: 'capitalize', transition: 'all 150ms ease',
+                      }}
+                    >{t}</button>
+                  ))}
+                </div>
+              </div>
+
+              <button
+                onClick={handleAllianceGenerate}
+                disabled={generating}
+                style={{
+                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                  padding: '8px 12px', borderRadius: 8, border: 'none',
+                  background: 'linear-gradient(135deg, #f59e0b, #d97706)',
+                  color: '#000', fontSize: 11, fontWeight: 700,
+                  cursor: generating ? 'wait' : 'pointer',
+                  opacity: generating ? 0.7 : 1,
+                  transition: 'opacity 150ms ease',
+                }}
+              >
+                {generating
+                  ? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating Alliance...</>
+                  : <><Zap size={13} /> Generate Alliance Petition</>
+                }
+              </button>
+            </div>
+          )}
+
+          {/* Single market selected — Generate petition */}
+          {selectedMarkets.length >= 1 && !isAllianceMode && !generated && (
+            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+              <div style={{
+                padding: '10px 12px', borderRadius: 8,
+                backgroundColor: 'var(--color-surface-el)',
+                border: '1px solid var(--color-border)',
+              }}>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  Selected Market
+                </div>
+                <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6, lineHeight: 1.3 }}>
+                  {selectedMarket!.title}
+                </div>
+                <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
+                  <span style={{ fontSize: 9, color: '#22c55e', fontWeight: 700 }}>
+                    Yes: {Math.round((selectedMarket!.yes_price || 0) * 100)}%
+                  </span>
+                  <span style={{ fontSize: 9, color: '#ef4444', fontWeight: 700 }}>
+                    No: {Math.round((selectedMarket!.no_price || 0) * 100)}%
+                  </span>
+                  <span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
+                    Vol: {((selectedMarket!.volume || 0) / 1000).toFixed(0)}k
+                  </span>
+                </div>
+              </div>
+
+              {/* Tone selector */}
+              <div>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>Tone</div>
+                <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+                  {['formal', 'urgent', 'passionate', 'grassroots'].map(t => (
+                    <button key={t} onClick={() => setTone(t)}
+                      style={{
+                        padding: '3px 10px', borderRadius: 99, border: 'none',
+                        fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                        backgroundColor: tone === t ? 'rgba(124, 58, 237, 0.2)' : 'var(--color-surface-el)',
+                        color: tone === t ? '#a78bfa' : 'var(--color-text-muted)',
+                        textTransform: 'capitalize', transition: 'all 150ms ease',
+                      }}
+                    >{t}</button>
+                  ))}
+                </div>
+              </div>
+
+              <button
+                onClick={handleGenerate}
+                disabled={generating}
+                style={{
+                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                  padding: '8px 12px', borderRadius: 8, border: 'none',
+                  background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+                  color: '#fff', fontSize: 11, fontWeight: 700,
+                  cursor: generating ? 'wait' : 'pointer',
+                  opacity: generating ? 0.7 : 1,
+                  transition: 'opacity 150ms ease',
+                }}
+              >
+                {generating
+                  ? <><Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
+                  : <><Sparkles size={13} /> Generate Petition</>
+                }
+              </button>
+            </div>
+          )}
+
+          {/* Generated petition preview */}
+          {generated && (
+            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+              <div style={{
+                padding: '10px 12px', borderRadius: 8,
+                backgroundColor: 'var(--color-surface-el)',
+                border: `1px solid ${generated.alliance_insight ? 'rgba(245, 158, 11, 0.3)' : 'rgba(124, 58, 237, 0.2)'}`,
+              }}>
+                <div style={{ fontSize: 10, fontWeight: 600, color: generated.alliance_insight ? '#f59e0b' : '#a78bfa', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  {generated.alliance_insight ? 'Alliance Petition' : 'Generated Preview'}
+                </div>
+                <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
+                  {generated.title}
+                </div>
+                <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
+                  {generated.summary}
+                </div>
+                <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
+                  {(generated.suggested_tags || []).map(tag => (
+                    <span key={tag} style={{
+                      fontSize: 8, padding: '1px 6px', borderRadius: 99,
+                      backgroundColor: generated.alliance_insight ? 'rgba(245, 158, 11, 0.1)' : 'rgba(124, 58, 237, 0.1)',
+                      color: generated.alliance_insight ? '#f59e0b' : '#a78bfa',
+                    }}>
+                      {tag}
+                    </span>
+                  ))}
+                </div>
+              </div>
+
+              {/* Alliance insight blocks */}
+              {generated.alliance_insight && (
+                <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+                  <div style={{
+                    padding: '8px 10px', borderRadius: 8,
+                    backgroundColor: 'rgba(245, 158, 11, 0.06)',
+                    border: '1px solid rgba(245, 158, 11, 0.15)',
+                  }}>
+                    <div style={{ fontSize: 9, fontWeight: 700, color: '#f59e0b', marginBottom: 3, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                      Why This Alliance Works
+                    </div>
+                    <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
+                      {generated.alliance_insight}
+                    </div>
+                  </div>
+                  <div style={{ display: 'flex', gap: 6 }}>
+                    {generated.group_a_appeal && (
+                      <div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+                        <div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[0]?.category] || '#71717a', marginBottom: 2 }}>GROUP A</div>
+                        <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_a_appeal}</div>
+                      </div>
+                    )}
+                    {generated.group_b_appeal && (
+                      <div style={{ flex: 1, padding: '6px 8px', borderRadius: 6, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
+                        <div style={{ fontSize: 8, fontWeight: 700, color: CATEGORY_COLORS[selectedMarkets[1]?.category] || '#71717a', marginBottom: 2 }}>GROUP B</div>
+                        <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.group_b_appeal}</div>
+                      </div>
+                    )}
+                  </div>
+                  {generated.impact_multiplier && (
+                    <div style={{
+                      padding: '6px 10px', borderRadius: 6,
+                      backgroundColor: 'rgba(34, 197, 94, 0.06)',
+                      border: '1px solid rgba(34, 197, 94, 0.15)',
+                    }}>
+                      <div style={{ fontSize: 9, fontWeight: 700, color: '#22c55e', marginBottom: 2 }}>IMPACT MULTIPLIER</div>
+                      <div style={{ fontSize: 9, color: 'var(--color-text-muted)', lineHeight: 1.3 }}>{generated.impact_multiplier}</div>
+                    </div>
+                  )}
+                </div>
+              )}
+
+              {/* Save / Regenerate / Activate */}
+              <div style={{ display: 'flex', gap: 6 }}>
+                <button
+                  onClick={handleSaveDraft}
+                  disabled={saving}
+                  style={{
+                    flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+                    padding: '7px 10px', borderRadius: 8, border: 'none',
+                    background: saving ? '#555' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+                    color: '#fff', fontSize: 10, fontWeight: 700,
+                    cursor: saving ? 'wait' : 'pointer',
+                    opacity: saving ? 0.7 : 1,
+                    transition: 'all 150ms ease',
+                  }}
+                >
+                  {saving
+                    ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Saving...</>
+                    : <><FileText size={12} /> Save as Draft</>
+                  }
+                </button>
+                <button
+                  onClick={() => setGenerated(null)}
+                  style={{
+                    padding: '7px 10px', borderRadius: 8,
+                    border: '1px solid var(--color-border)',
+                    backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+                    fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                    transition: 'all 150ms ease',
+                  }}
+                  onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+                  onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+                >
+                  Redo
+                </button>
+              </div>
+
+              {/* Cross-Platform Posting */}
+              <div>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  Cross-Platform Post
+                </div>
+                <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+                  {Object.entries(PLATFORMS).map(([key, platform]) => (
+                    <button
+                      key={key}
+                      onClick={() => handleCopyAndOpen(key)}
+                      style={{
+                        display: 'flex', alignItems: 'center', gap: 8,
+                        padding: '7px 10px', borderRadius: 8,
+                        border: '1px solid var(--color-border)',
+                        backgroundColor: 'transparent', color: 'var(--color-text)',
+                        fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                        transition: 'all 150ms ease', textAlign: 'left',
+                      }}
+                      onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; e.currentTarget.style.borderColor = platform.color; }}
+                      onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+                    >
+                      <div style={{
+                        width: 22, height: 22, borderRadius: 6, flexShrink: 0,
+                        backgroundColor: `${platform.color}20`,
+                        display: 'flex', alignItems: 'center', justifyContent: 'center',
+                      }}>
+                        <ExternalLink size={11} style={{ color: platform.color }} />
+                      </div>
+                      <span style={{ flex: 1 }}>{platform.name}</span>
+                      {copiedPlatform === key
+                        ? <Check size={12} style={{ color: '#22c55e' }} />
+                        : <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
+                      }
+                    </button>
+                  ))}
+                </div>
+              </div>
+
+              {/* Email Campaign Link */}
+              <button
+                onClick={async () => {
+                  if (!generated) return;
+                  // Save petition first, then redirect to campaigns
+                  setSaving(true);
+                  try {
+                    const res = await fetch('/api/petitions', {
+                      method: 'POST',
+                      headers: { 'Content-Type': 'application/json' },
+                      body: JSON.stringify({
+                        title: generated.title,
+                        summary: generated.summary,
+                        body_html: generated.body_html || `<p>${generated.summary}</p>`,
+                        body_text: generated.body_text || generated.summary,
+                        target: generated.suggested_target,
+                        tags: generated.suggested_tags,
+                        signature_goal: generated.suggested_goal || 10000,
+                        status: 'active',
+                      }),
+                    });
+                    if (res.ok) {
+                      showToast('Petition saved & ready for campaign!', 'success');
+                      // Navigate to campaigns tab after short delay
+                      setTimeout(() => {
+                        const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
+                        window.dispatchEvent(event);
+                      }, 500);
+                    } else {
+                      showToast('Failed to save petition for campaign', 'error');
+                    }
+                  } catch {
+                    showToast('Network error', 'error');
+                  } finally {
+                    setSaving(false);
+                  }
+                }}
+                disabled={saving}
+                style={{
+                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                  padding: '7px 10px', borderRadius: 8,
+                  border: '1px solid rgba(124, 58, 237, 0.3)',
+                  backgroundColor: 'rgba(124, 58, 237, 0.08)',
+                  color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                  transition: 'all 150ms ease',
+                }}
+                onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
+                onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
+              >
+                <Mail size={12} /> Create Email Campaign
+              </button>
+            </div>
+          )}
+
+          {/* Petition selected — show linked markets + cross-post */}
+          {selectedPetition && selectedMarkets.length === 0 && !generated && (
+            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
+              <div style={{
+                padding: '10px 12px', borderRadius: 8,
+                backgroundColor: 'var(--color-surface-el)',
+                border: '1px solid var(--color-border)',
+              }}>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  Selected Petition
+                </div>
+                <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text)', marginBottom: 4, lineHeight: 1.3 }}>
+                  {selectedPetition.title}
+                </div>
+                <div style={{ fontSize: 10, color: 'var(--color-text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
+                  {selectedPetition.summary}
+                </div>
+                <div style={{ display: 'flex', gap: 8 }}>
+                  <span style={{
+                    fontSize: 9, padding: '2px 6px', borderRadius: 4,
+                    backgroundColor: STATUS_STYLES[selectedPetition.status]?.bg,
+                    color: STATUS_STYLES[selectedPetition.status]?.text,
+                    fontWeight: 600, textTransform: 'capitalize',
+                  }}>
+                    {selectedPetition.status}
+                  </span>
+                  <span style={{ fontSize: 9, color: 'var(--color-text-muted)' }}>
+                    {(selectedPetition.signature_count ?? 0).toLocaleString()} / {(selectedPetition.signature_goal ?? 1).toLocaleString()} sigs
+                  </span>
+                </div>
+              </div>
+
+              {/* Cross-Platform Posting for existing petition */}
+              <div>
+                <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  Post to Platform
+                </div>
+                <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+                  {Object.entries(PLATFORMS).map(([key, platform]) => (
+                    <button
+                      key={key}
+                      onClick={() => {
+                        const text = `${selectedPetition.title}\n\n${selectedPetition.summary}`;
+                        navigator.clipboard.writeText(text).then(() => {
+                          setCopiedPlatform(key);
+                          setTimeout(() => setCopiedPlatform(null), 2000);
+                          window.open(platform.url, '_blank');
+                        });
+                      }}
+                      style={{
+                        display: 'flex', alignItems: 'center', gap: 8,
+                        padding: '7px 10px', borderRadius: 8,
+                        border: '1px solid var(--color-border)',
+                        backgroundColor: 'transparent', color: 'var(--color-text)',
+                        fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                        transition: 'all 150ms ease', textAlign: 'left',
+                      }}
+                      onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+                      onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+                    >
+                      <div style={{
+                        width: 22, height: 22, borderRadius: 6, flexShrink: 0,
+                        backgroundColor: `${platform.color}20`,
+                        display: 'flex', alignItems: 'center', justifyContent: 'center',
+                      }}>
+                        <ExternalLink size={11} style={{ color: platform.color }} />
+                      </div>
+                      <span style={{ flex: 1 }}>Copy & Open {platform.name}</span>
+                      {copiedPlatform === key
+                        ? <Check size={12} style={{ color: '#22c55e' }} />
+                        : <Copy size={12} style={{ color: 'var(--color-text-muted)' }} />
+                      }
+                    </button>
+                  ))}
+                </div>
+              </div>
+
+              {/* Activate / Archive buttons */}
+              <div style={{ display: 'flex', gap: 6 }}>
+                {selectedPetition.status === 'draft' && (
+                  <button
+                    onClick={() => handleActivatePetition(selectedPetition.id)}
+                    style={{
+                      flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
+                      padding: '7px 10px', borderRadius: 8, border: 'none',
+                      background: 'linear-gradient(135deg, #22c55e, #16a34a)',
+                      color: '#fff', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                      transition: 'all 150ms ease',
+                    }}
+                  >
+                    <Zap size={11} /> Activate
+                  </button>
+                )}
+                <button
+                  onClick={() => handleArchivePetition(selectedPetition.id)}
+                  style={{
+                    padding: '7px 10px', borderRadius: 8,
+                    border: '1px solid rgba(239, 68, 68, 0.3)',
+                    backgroundColor: 'rgba(239, 68, 68, 0.06)',
+                    color: '#ef4444', fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                    transition: 'all 150ms ease',
+                  }}
+                  onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.12)'; }}
+                  onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(239, 68, 68, 0.06)'; }}
+                >
+                  <X size={11} /> Archive
+                </button>
+              </div>
+
+              <button
+                onClick={() => {
+                  showToast('Opening campaign builder...', 'info');
+                  const event = new CustomEvent('patty-navigate', { detail: { tab: 'campaigns' } });
+                  window.dispatchEvent(event);
+                }}
+                style={{
+                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                  padding: '7px 10px', borderRadius: 8,
+                  border: '1px solid rgba(124, 58, 237, 0.3)',
+                  backgroundColor: 'rgba(124, 58, 237, 0.08)',
+                  color: '#a78bfa', fontSize: 10, fontWeight: 700, cursor: 'pointer',
+                  transition: 'all 150ms ease',
+                }}
+                onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.15)'; }}
+                onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'rgba(124, 58, 237, 0.08)'; }}
+              >
+                <Mail size={12} /> Create Campaign from Petition
+              </button>
+
+              {/* Share Link */}
+              <button
+                onClick={() => {
+                  const shareUrl = `${window.location.origin}/petition/${selectedPetition.id}`;
+                  const doCopy = () => {
+                    if (navigator.clipboard?.writeText) return navigator.clipboard.writeText(shareUrl);
+                    const ta = document.createElement('textarea');
+                    ta.value = shareUrl;
+                    ta.style.position = 'fixed';
+                    ta.style.left = '-9999px';
+                    document.body.appendChild(ta);
+                    ta.select();
+                    document.execCommand('copy');
+                    document.body.removeChild(ta);
+                    return Promise.resolve();
+                  };
+                  doCopy().then(() => showToast('Petition link copied!', 'success'))
+                    .catch(() => showToast('Failed to copy link', 'error'));
+                }}
+                style={{
+                  display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+                  padding: '7px 10px', borderRadius: 8,
+                  border: '1px solid var(--color-border)',
+                  backgroundColor: 'transparent',
+                  color: 'var(--color-text-secondary)', fontSize: 10, fontWeight: 600, cursor: 'pointer',
+                  transition: 'all 150ms ease',
+                }}
+                onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+                onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+              >
+                <Link2 size={11} /> Share Petition Link
+              </button>
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   Main OrbitTab Component
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface OrbitTabProps {
+  pulseTopic?: string | null;
+  onPulseTopicConsumed?: () => void;
+}
+
+export default function OrbitTab({ pulseTopic, onPulseTopicConsumed }: OrbitTabProps = {}) {
+  /* ── Data state ───────────────────────────────────────────────────────── */
+  const [markets, setMarkets] = useState<KalshiMarket[]>(MOCK_MARKETS);
+  const [petitions, setPetitions] = useState<Petition[]>(MOCK_PETITIONS);
+  const [links, setLinks] = useState<OrbitLink[]>(MOCK_LINKS);
+  const [articles, setArticles] = useState<RSSArticle[]>(MOCK_ARTICLES);
+  const [stats, setStats] = useState<OrbitStats>(MOCK_STATS);
+  const [trendingTopics, setTrendingTopics] = useState<TrendingTopic[]>([]);
+  const [agents, setAgents] = useState<AgentInfo[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [rssLoading, setRssLoading] = useState(false);
+  const [pulseGenerating, setPulseGenerating] = useState(false);
+  const [pulseResult, setPulseResult] = useState<{ petition: any; influence_analysis: string } | null>(null);
+  const [refreshing, setRefreshing] = useState(false);
+  const [usingMock, setUsingMock] = useState(true);
+
+  /* ── Interaction state ────────────────────────────────────────────────── */
+  const [hoveredMarket, setHoveredMarket] = useState<string | null>(null);
+  const [hoveredPetition, setHoveredPetition] = useState<string | null>(null);
+  const [selectedMarkets, setSelectedMarkets] = useState<string[]>([]);
+  const [selectedPetition, setSelectedPetition] = useState<string | null>(null);
+
+  // Market selection handler: normal click = single select, shift = multi (max 2)
+  const handleSelectMarket = useCallback((id: string | null, shift?: boolean) => {
+    if (!id) {
+      setSelectedMarkets([]);
+      return;
+    }
+    setSelectedMarkets(prev => {
+      // Deselect if already selected
+      if (prev.includes(id)) return prev.filter(x => x !== id);
+      // Shift+click: add to selection (max 2)
+      if (shift && prev.length < 2) return [...prev, id];
+      if (shift && prev.length >= 2) return [prev[1], id]; // drop oldest
+      // Normal click: replace
+      return [id];
+    });
+    setSelectedPetition(null);
+  }, []);
+
+  /* ── Fetch orbit data ─────────────────────────────────────────────────── */
+  const fetchOrbitData = useCallback(async () => {
+    setLoading(true);
+    try {
+      const res = await fetch('/api/orbit');
+      if (res.ok) {
+        const data = await res.json();
+        if (data.markets?.length > 0) {
+          // Map DB fields to client-expected fields
+          const mappedMarkets = data.markets.map((m: any) => {
+            // DB stores prices as integers 0-100; client expects floats 0-1
+            const rawYes = m.yes_price ?? m.last_price ?? m.yes_bid ?? 0;
+            const yesPrice = rawYes > 1 ? rawYes / 100 : rawYes;
+            const rawNo = m.no_price ?? m.no_bid ?? (rawYes != null ? (rawYes > 1 ? 100 : 1) - rawYes : 0);
+            const noPrice = rawNo > 1 ? rawNo / 100 : rawNo;
+            return {
+              ...m,
+              yes_price: yesPrice,
+              no_price: noPrice,
+              volume: m.volume ?? 0,
+              category: m.category || 'Other',
+            };
+          });
+          setMarkets(mappedMarkets);
+          setPetitions(data.petitions || []);
+          setLinks(data.links || []);
+          if (data.trending?.length > 0) setTrendingTopics(data.trending);
+          setStats({
+            ...MOCK_STATS,
+            ...data.stats,
+            articles_today: data.stats?.articles_today ?? data.stats?.recent_articles ?? MOCK_STATS.articles_today,
+            avg_sentiment: data.stats?.avg_sentiment ?? MOCK_STATS.avg_sentiment,
+          });
+          setUsingMock(false);
+        }
+      }
+    } catch {
+      // Keep mock data on failure
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  /* ── Fetch RSS data ───────────────────────────────────────────────────── */
+  const fetchRSSData = useCallback(async () => {
+    setRssLoading(true);
+    try {
+      const res = await fetch('/api/orbit/rss');
+      if (res.ok) {
+        const data = await res.json();
+        if (data.articles?.length > 0) {
+          setArticles(data.articles);
+          if (data.stats) {
+            setStats(prev => ({
+              ...prev,
+              active_feeds: data.stats.active_feeds ?? prev.active_feeds,
+              articles_today: data.stats.articles_today ?? prev.articles_today,
+              avg_sentiment: data.stats.avg_sentiment ?? prev.avg_sentiment,
+            }));
+          }
+          setUsingMock(false);
+        }
+      }
+    } catch {
+      // Keep mock data
+    } finally {
+      setRssLoading(false);
+    }
+  }, []);
+
+  /* ── Fetch agent status ──────────────────────────────────────────────── */
+  const fetchAgentStatus = useCallback(async () => {
+    try {
+      const res = await fetch('/api/orbit/agents');
+      if (res.ok) {
+        const data = await res.json();
+        setAgents(data.agents || []);
+      }
+    } catch { /* ignore */ }
+  }, []);
+
+  const handleRefreshFeeds = useCallback(async () => {
+    setRefreshing(true);
+    await fetchRSSData();
+    setRefreshing(false);
+  }, [fetchRSSData]);
+
+  useEffect(() => {
+    fetchOrbitData();
+    fetchRSSData();
+    fetchAgentStatus();
+  }, [fetchOrbitData, fetchRSSData, fetchAgentStatus]);
+
+  /* ── Auto-generate petition from Pulse topic ───────────────────────── */
+  useEffect(() => {
+    if (!pulseTopic) return;
+    setPulseGenerating(true);
+    setPulseResult(null);
+    fetch('/api/orbit/generate', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ topic_title: pulseTopic, tone: 'urgent' }),
+    })
+      .then(res => res.ok ? res.json() : Promise.reject(res))
+      .then(data => {
+        setPulseResult({ petition: data.petition, influence_analysis: data.influence_analysis || '' });
+        // Refresh data to show new petition in mind map
+        fetchOrbitData();
+      })
+      .catch(err => {
+        console.error('[OrbitTab] Pulse petition generation failed:', err);
+        setPulseResult(null);
+      })
+      .finally(() => {
+        setPulseGenerating(false);
+        onPulseTopicConsumed?.();
+      });
+  }, [pulseTopic, onPulseTopicConsumed, fetchOrbitData]);
+
+  /* ── Derived selection objects ─────────────────────────────────────────── */
+  const selectedMarketObjs = selectedMarkets.map(id => markets.find(m => m.id === id)).filter(Boolean) as KalshiMarket[];
+  const selectedPetitionObj = selectedPetition ? petitions.find(p => p.id === selectedPetition) || null : null;
+
+  const sentimentInfo = sentimentLabel(stats.avg_sentiment);
+
+  /* ─── Render ──────────────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden' }}>
+
+      {/* ── Stats Bar ────────────────────────────────────────────────────── */}
+      <div style={{
+        display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
+        padding: '10px 16px', borderRadius: 12,
+        background: 'linear-gradient(135deg, var(--color-surface), var(--color-surface-el))',
+        border: '1px solid var(--color-border)',
+        flexShrink: 0,
+      }}>
+        {usingMock && (
+          <div style={{
+            display: 'flex', alignItems: 'center', gap: 4,
+            padding: '3px 10px', borderRadius: 99,
+            backgroundColor: 'rgba(245, 158, 11, 0.15)',
+            border: '1px solid rgba(245, 158, 11, 0.3)',
+          }}>
+            <AlertCircle size={11} style={{ color: '#f59e0b' }} />
+            <span style={{ fontSize: 9, fontWeight: 600, color: '#f59e0b' }}>Demo Data</span>
+          </div>
+        )}
+        <StatPill icon={<BarChart3 size={12} />} label="Markets" value={stats.total_markets} color="#3b82f6" />
+        <StatPill icon={<Link2 size={12} />} label="Linked" value={stats.linked_petitions} color="#a78bfa" />
+        <StatPill icon={<Rss size={12} />} label="Feeds" value={stats.active_feeds} color="#22c55e" />
+        <StatPill icon={<Newspaper size={12} />} label="Today" value={stats.articles_today} color="#06b6d4" />
+        <StatPill icon={<TrendingUp size={12} />} label="Trending" value={trendingTopics.length} color={TRENDING_COLOR} />
+        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+          <span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>Sentiment</span>
+          <span style={{
+            fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
+            backgroundColor: `${sentimentInfo.color}20`, color: sentimentInfo.color,
+          }}>
+            {sentimentInfo.label} ({(stats.avg_sentiment ?? 0).toFixed(2)})
+          </span>
+        </div>
+        {/* Agent Status Indicators */}
+        {agents.length > 0 && (
+          <div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 99,
+            backgroundColor: 'rgba(124,58,237,0.08)', border: '1px solid rgba(124,58,237,0.15)' }}
+            title={agents.map(a => `${a.name}: ${a.status}`).join(', ')}
+          >
+            <Activity size={10} style={{ color: '#a78bfa' }} />
+            <span style={{ fontSize: 9, fontWeight: 600, color: 'var(--color-text-muted)' }}>Agents</span>
+            {agents.map(a => (
+              <div key={a.name} title={`${a.name} (${a.role}) — ${a.status}`}
+                style={{
+                  width: 7, height: 7, borderRadius: '50%',
+                  backgroundColor: a.status === 'online' ? '#22c55e' : a.status === 'error' ? '#f59e0b' : '#ef4444',
+                  border: '1px solid rgba(255,255,255,0.15)',
+                  transition: 'background-color 300ms ease',
+                }}
+              />
+            ))}
+            <span style={{ fontSize: 9, fontWeight: 700, color: '#22c55e' }}>
+              {agents.filter(a => a.status === 'online').length}/{agents.length}
+            </span>
+          </div>
+        )}
+
+        <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
+          {loading && <Loader2 size={13} style={{ color: 'var(--color-text-muted)', animation: 'spin 1s linear infinite' }} />}
+          <button
+            onClick={() => { fetchOrbitData(); fetchRSSData(); fetchAgentStatus(); }}
+            style={{
+              display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
+              borderRadius: 6, border: '1px solid var(--color-border)',
+              backgroundColor: 'transparent', color: 'var(--color-text-secondary)',
+              cursor: 'pointer', fontSize: 10, fontWeight: 600,
+              transition: 'all 150ms ease',
+            }}
+            onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+            onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
+          >
+            <RefreshCw size={11} /> Sync
+          </button>
+        </div>
+      </div>
+
+      {/* ── Pulse Topic Banner ──────────────────────────────────────────── */}
+      {(pulseGenerating || pulseResult) && (
+        <div style={{
+          display: 'flex', alignItems: 'center', gap: 10,
+          padding: '10px 16px', borderRadius: 10,
+          background: pulseGenerating
+            ? 'linear-gradient(135deg, rgba(124,58,237,0.15), rgba(59,130,246,0.15))'
+            : 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(124,58,237,0.12))',
+          border: `1px solid ${pulseGenerating ? 'rgba(124,58,237,0.3)' : 'rgba(34,197,94,0.3)'}`,
+          flexShrink: 0,
+        }}>
+          {pulseGenerating ? (
+            <>
+              <Loader2 size={16} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
+              <span style={{ fontSize: 12, fontWeight: 600, color: '#a78bfa' }}>
+                Generating petition from Pulse topic...
+              </span>
+            </>
+          ) : pulseResult ? (
+            <>
+              <Sparkles size={16} style={{ color: '#22c55e' }} />
+              <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
+                Petition created: &ldquo;{pulseResult.petition?.title}&rdquo;
+              </span>
+              <span style={{ fontSize: 10, color: 'var(--color-text-muted)', marginLeft: 4 }}>
+                {pulseResult.influence_analysis?.slice(0, 120)}...
+              </span>
+              <button
+                onClick={() => setPulseResult(null)}
+                style={{
+                  marginLeft: 'auto', padding: '2px 8px', borderRadius: 4,
+                  border: '1px solid var(--color-border)', backgroundColor: 'transparent',
+                  color: 'var(--color-text-muted)', fontSize: 10, cursor: 'pointer',
+                }}
+              >
+                <X size={12} />
+              </button>
+            </>
+          ) : null}
+        </div>
+      )}
+
+      {/* ── Main Layout: Mind Map | Right Panels ─────────────────────────── */}
+      <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
+        {/* Mind Map (left, 60%) */}
+        <div style={{
+          flex: '0 0 60%', minWidth: 0,
+          height: 'calc(100vh - 140px)',
+          backgroundColor: 'var(--color-surface)',
+          border: '1px solid var(--color-border)',
+          borderRadius: 12, overflow: 'hidden', position: 'relative',
+        }}>
+          {loading && !usingMock ? (
+            <div style={{
+              display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%',
+              flexDirection: 'column', gap: 8,
+            }}>
+              <Loader2 size={24} style={{ color: '#a78bfa', animation: 'spin 1s linear infinite' }} />
+              <span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>Loading orbit data...</span>
+            </div>
+          ) : (
+            <MindMap
+              markets={markets.slice(0, 14)}
+              petitions={petitions.slice(0, 6)}
+              links={links}
+              trendingTopics={trendingTopics.slice(0, 6)}
+              hoveredMarket={hoveredMarket}
+              hoveredPetition={hoveredPetition}
+              selectedMarkets={selectedMarkets}
+              selectedPetition={selectedPetition}
+              onHoverMarket={setHoveredMarket}
+              onHoverPetition={setHoveredPetition}
+              onSelectMarket={handleSelectMarket}
+              onSelectPetition={(id) => { setSelectedPetition(id); if (id) setSelectedMarkets([]); }}
+              onSelectTrending={(id) => {
+                // When a trending topic is clicked, auto-generate a petition from it
+                if (id) {
+                  const topic = trendingTopics.find(t => t.id === id);
+                  if (topic) {
+                    window.dispatchEvent(new CustomEvent('patty-navigate', { detail: { tab: 'trending' } }));
+                  }
+                }
+              }}
+            />
+          )}
+
+          {/* Watermark */}
+          <div style={{
+            position: 'absolute', bottom: 8, right: 12,
+            fontSize: 9, color: 'var(--color-text-muted)', opacity: 0.5,
+            fontWeight: 600, letterSpacing: '0.04em',
+          }}>
+            ORBIT MIND MAP
+          </div>
+        </div>
+
+        {/* Right panels (40%, stacked) */}
+        <div style={{ flex: '0 0 40%', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12, height: 'calc(100vh - 140px)' }}>
+          {/* Pulse panel (top 55%) */}
+          <div style={{ flex: '0 0 55%', minHeight: 0 }}>
+            <PulsePanel
+              articles={articles}
+              loading={rssLoading}
+              onRefresh={handleRefreshFeeds}
+              refreshing={refreshing}
+            />
+          </div>
+
+          {/* Actions panel (bottom 45%) */}
+          <div style={{ flex: 1, minHeight: 0 }}>
+            <ActionsPanel
+              selectedMarkets={selectedMarketObjs}
+              selectedPetition={selectedPetitionObj}
+              onClearSelection={() => { setSelectedMarkets([]); setSelectedPetition(null); }}
+              onRefresh={fetchOrbitData}
+            />
+          </div>
+        </div>
+      </div>
+
+      {/* Animation keyframes */}
+      <style>{`
+        @keyframes spin {
+          from { transform: rotate(0deg); }
+          to { transform: rotate(360deg); }
+        }
+        @keyframes fadeInUp {
+          from { opacity: 0; transform: translateX(-50%) translateY(8px); }
+          to { opacity: 1; transform: translateX(-50%) translateY(0); }
+        }
+      `}</style>
+    </div>
+  );
+}
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   StatPill Utility
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+function StatPill({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: number; color: string }) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
+      <div style={{ color, display: 'flex', alignItems: 'center' }}>{icon}</div>
+      <span style={{ fontSize: 10, color: 'var(--color-text-muted)', fontWeight: 600 }}>{label}</span>
+      <span style={{ fontSize: 11, fontWeight: 800, color: 'var(--color-text)' }}>{value}</span>
+    </div>
+  );
+}
diff --git a/components/petitions/PetitionsTab.tsx b/components/petitions/PetitionsTab.tsx
new file mode 100644
index 0000000..e350b73
--- /dev/null
+++ b/components/petitions/PetitionsTab.tsx
@@ -0,0 +1,1101 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+  Plus, Megaphone, Search, ChevronDown, ChevronUp,
+  Sparkles, Target, Users, Share2, Calendar, Edit3,
+  Send, Loader2, X, Tag, Mail as MailIcon, DollarSign, ExternalLink,
+} from 'lucide-react';
+import { Grant, formatAmount } from '../grants/types';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const PET_SORT_OPTIONS = [
+  { value: 'sigs_desc', label: 'Most Signatures' },
+  { value: 'date_desc', label: 'Newest First' },
+  { value: 'date_asc', label: 'Oldest First' },
+  { value: 'status', label: 'Status A-Z' },
+];
+
+const PET_SORT_CONFIGS: Record<string, SortConfig> = {
+  sigs_desc: { key: 'signature_count', direction: 'desc', type: 'number' },
+  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+  status: { key: 'status', direction: 'asc', type: 'string' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Petition {
+  id: string;
+  title: string;
+  slug: string;
+  summary: string | null;
+  body_html: string;
+  body_text: string | null;
+  target: string | null;
+  target_emails: string[] | null;
+  status: string;
+  category: string | null;
+  tags: string[] | null;
+  signature_count: number;
+  signature_goal: number;
+  share_count: number;
+  org_type: string | null;
+  compliance_flags: Record<string, unknown> | null;
+  external_url?: string | null;
+  distribution_platform?: string;
+  created_at: string;
+}
+
+/**
+ * Basic HTML tag stripper for display purposes.
+ * All petition content is created by the admin user or generated by Gemini AI,
+ * so it is trusted internal content. This function strips tags for plain text display.
+ */
+function stripHtml(html: string): string {
+  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function PetitionsTab({ selectedId, onSelectedConsumed, onViewGrant }: { selectedId?: number | null; onSelectedConsumed?: () => void; onViewGrant?: (id: string) => void } = {}) {
+  const { addToast } = useToast();
+  const [petitions, setPetitions] = useState<Petition[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [filter, setFilter] = useState('all');
+  const [search, setSearch] = useState('');
+  const debouncedSearch = useDebounce(search, 300);
+  const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
+  const [expanded, setExpanded] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState('date_desc');
+  const sortedPetitions = useClientSort(petitions, sortBy, PET_SORT_CONFIGS);
+
+  // AI modal state
+  const [showAiModal, setShowAiModal] = useState(false);
+  const [aiTopic, setAiTopic] = useState('');
+  const [aiTarget, setAiTarget] = useState('');
+  const [aiTone, setAiTone] = useState('formal');
+  const [aiCategory, setAiCategory] = useState('policy');
+  const [aiGenerating, setAiGenerating] = useState(false);
+  const [aiResult, setAiResult] = useState<{
+    title: string; summary: string; body_html: string; body_text: string;
+    suggested_target: string; suggested_tags: string[]; suggested_goal: number;
+  } | null>(null);
+  const [savingDraft, setSavingDraft] = useState(false);
+
+  // Related grants state
+  const [petitionGrants, setPetitionGrants] = useState<Record<string, Grant[]>>({});
+  const [loadingGrants, setLoadingGrants] = useState<string | null>(null);
+
+  // Edit modal state
+  const [editPetition, setEditPetition] = useState<Petition | null>(null);
+  const [editTitle, setEditTitle] = useState('');
+  const [editSummary, setEditSummary] = useState('');
+  const [editTarget, setEditTarget] = useState('');
+  const [editGoal, setEditGoal] = useState(100);
+  const [editStatus, setEditStatus] = useState('draft');
+  const [editSaving, setEditSaving] = useState(false);
+
+  // Deploy modal state
+  const [showDeployModal, setShowDeployModal] = useState(false);
+  const [deployPetition, setDeployPetition] = useState<Petition | null>(null);
+  const [deployPlatforms, setDeployPlatforms] = useState<Record<string, boolean>>({ moveon: true, change_org: true, we_the_people: false, custom: false });
+  const [deploying, setDeploying] = useState(false);
+  const [deployResults, setDeployResults] = useState<Array<{ platform: string; platform_name: string; creation_url: string; formatted_content: Record<string, unknown>; instructions: string }>>([]);
+  const [deployCustomUrl, setDeployCustomUrl] = useState('');
+  const [copiedField, setCopiedField] = useState<string | null>(null);
+
+  /* ── Fetch ──────────────────────────────────────────────────── */
+  const fetchPetitions = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      if (filter !== 'all') params.set('status', filter);
+      if (debouncedSearch) params.set('search', debouncedSearch);
+      const res = await fetch(`/api/petitions?${params.toString()}`);
+      if (res.ok) {
+        const data = await res.json();
+        setPetitions(data.rows || []);
+        setStatusCounts(data.statusCounts || {});
+      }
+    } catch (err) {
+      console.error('Fetch petitions error:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [filter, debouncedSearch]);
+
+  useEffect(() => { fetchPetitions(); }, [fetchPetitions]);
+
+  // Auto-expand petition from cross-tab navigation (Topics → Petitions)
+  useEffect(() => {
+    if (selectedId && petitions.length > 0) {
+      setExpanded(String(selectedId));
+      onSelectedConsumed?.();
+      // Scroll into view after render
+      setTimeout(() => {
+        document.getElementById(`petition-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
+      }, 100);
+    }
+  }, [selectedId, petitions.length, onSelectedConsumed]);
+
+  /* ── Escape key handler for modals ──────────────────────────── */
+  useEffect(() => {
+    if (!showAiModal && !editPetition && !showDeployModal) return;
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (showAiModal) setShowAiModal(false);
+        if (editPetition) setEditPetition(null);
+        if (showDeployModal) { setShowDeployModal(false); setDeployResults([]); }
+      }
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [showAiModal, editPetition, showDeployModal]);
+
+  /* ── AI Generate ────────────────────────────────────────────── */
+  async function handleAiGenerate() {
+    if (!aiTopic.trim()) return;
+    setAiGenerating(true);
+    setAiResult(null);
+    try {
+      const res = await fetch('/api/petitions/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ topic: aiTopic, target: aiTarget, tone: aiTone, category: aiCategory }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setAiResult(data);
+        addToast('Petition generated', 'success');
+      } else {
+        addToast('AI generation failed', 'error');
+      }
+    } catch (err) {
+      console.error('AI generate error:', err);
+      addToast('AI generation failed', 'error');
+    } finally {
+      setAiGenerating(false);
+    }
+  }
+
+  /* ── Save Draft ─────────────────────────────────────────────── */
+  async function handleSaveDraft() {
+    if (!aiResult) return;
+    setSavingDraft(true);
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: aiResult.title,
+          summary: aiResult.summary,
+          body_html: aiResult.body_html,
+          body_text: aiResult.body_text,
+          target: aiResult.suggested_target,
+          category: aiCategory,
+          tags: aiResult.suggested_tags,
+          signature_goal: aiResult.suggested_goal,
+          status: 'draft',
+        }),
+      });
+      if (res.ok) {
+        setShowAiModal(false);
+        setAiResult(null);
+        setAiTopic('');
+        setAiTarget('');
+        fetchPetitions();
+        addToast('Draft saved', 'success');
+      } else {
+        addToast('Failed to save draft', 'error');
+      }
+    } catch (err) {
+      console.error('Save draft error:', err);
+      addToast('Failed to save draft', 'error');
+    } finally {
+      setSavingDraft(false);
+    }
+  }
+
+  /* ── Edit Petition ──────────────────────────────────────────── */
+  function openEdit(p: Petition) {
+    setEditPetition(p);
+    setEditTitle(p.title);
+    setEditSummary(p.summary || '');
+    setEditTarget(p.target || '');
+    setEditGoal(p.signature_goal);
+    setEditStatus(p.status);
+  }
+
+  async function handleSaveEdit() {
+    if (!editPetition) return;
+    setEditSaving(true);
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          id: editPetition.id,
+          title: editTitle,
+          summary: editSummary,
+          target: editTarget,
+          signature_goal: editGoal,
+          status: editStatus,
+        }),
+      });
+      if (res.ok) {
+        setEditPetition(null);
+        fetchPetitions();
+        addToast('Petition updated', 'success');
+      } else {
+        addToast('Failed to update petition', 'error');
+      }
+    } catch (err) {
+      console.error('Save edit error:', err);
+      addToast('Failed to update petition', 'error');
+    } finally {
+      setEditSaving(false);
+    }
+  }
+
+  /* ── Deploy to Platforms ──────────────────────────────────── */
+  const DEPLOY_PLATFORMS = [
+    { key: 'moveon', name: 'MoveOn', url: 'sign.moveon.org', icon: '🟡' },
+    { key: 'change_org', name: 'Change.org', url: 'change.org', icon: '🔴' },
+    { key: 'we_the_people', name: 'We The People', url: 'petitions.whitehouse.gov', icon: '🏛️' },
+    { key: 'custom', name: 'Custom URL', url: '', icon: '🔗' },
+  ];
+
+  async function handleDeploy() {
+    if (!deployPetition) return;
+    const selected = Object.entries(deployPlatforms).filter(([, v]) => v).map(([k]) => k);
+    if (selected.length === 0) { addToast('Select at least one platform', 'error'); return; }
+    setDeploying(true);
+    setDeployResults([]);
+    const results: typeof deployResults = [];
+    for (const platform of selected) {
+      try {
+        const res = await fetch('/api/orbit/post', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({
+            petition_id: deployPetition.id,
+            platform,
+            custom_url: platform === 'custom' ? deployCustomUrl : undefined,
+          }),
+        });
+        if (res.ok) {
+          const data = await res.json();
+          results.push({
+            platform,
+            platform_name: data.platform_name,
+            creation_url: data.creation_url,
+            formatted_content: data.formatted_content,
+            instructions: data.instructions,
+          });
+        } else {
+          addToast(`Failed to deploy to ${platform}`, 'error');
+        }
+      } catch {
+        addToast(`Error deploying to ${platform}`, 'error');
+      }
+    }
+    setDeployResults(results);
+    setDeploying(false);
+    if (results.length > 0) addToast(`Generated content for ${results.length} platform(s)`, 'success');
+  }
+
+  function copyToClipboard(text: string, label: string) {
+    navigator.clipboard.writeText(String(text)).then(() => {
+      setCopiedField(label);
+      setTimeout(() => setCopiedField(null), 2000);
+    });
+  }
+
+  /* ── Find Related Grants ──────────────────────────────────── */
+  async function findRelatedGrants(petitionId: string) {
+    setLoadingGrants(petitionId);
+    try {
+      const res = await fetch('/api/grants-proxy/grants');
+      if (res.ok) {
+        const data = await res.json();
+        setPetitionGrants(prev => ({ ...prev, [petitionId]: data.rows || [] }));
+      }
+    } catch (err) {
+      console.error('Find grants error:', err);
+      addToast('Failed to find related grants', 'error');
+    } finally {
+      setLoadingGrants(null);
+    }
+  }
+
+  /* ── Helpers ────────────────────────────────────────────────── */
+  const statusFilters = ['all', 'draft', 'active', 'paused', 'closed', 'delivered'];
+
+  const statusBadgeClass: Record<string, string> = {
+    draft: 'badge-draft',
+    active: 'badge-active',
+    paused: 'badge-paused',
+    closed: 'badge-closed',
+    delivered: 'badge-delivered',
+  };
+
+  const categoryLabel = (c: string | null) => {
+    if (!c) return '';
+    return c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
+  };
+
+  const formatDate = (d: string) => {
+    return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+  };
+
+  const sigPct = (count: number, goal: number) => {
+    if (goal <= 0) return 0;
+    return Math.min(Math.round((count / goal) * 100), 100);
+  };
+
+  /* ─── Render ────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header Row */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+            My Petitions
+          </h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            Create, manage, and track your advocacy campaigns
+          </p>
+        </div>
+        <button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
+          <Sparkles size={16} />
+          AI Create Petition
+        </button>
+      </div>
+
+      {/* Filter Row */}
+      <div className="flex items-center gap-3 mb-6 flex-wrap">
+        <SortDropdown options={PET_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+        <div className="flex-1 relative" style={{ minWidth: 200 }}>
+          <Search
+            size={16}
+            style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
+          />
+          <input
+            className="input"
+            placeholder="Search by title or target..."
+            style={{ paddingLeft: 36 }}
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+          />
+        </div>
+        <div className="flex items-center gap-1 flex-wrap">
+          {statusFilters.map((s) => (
+            <button
+              key={s}
+              onClick={() => setFilter(s)}
+              className={`btn btn-sm ${filter === s ? 'btn-primary' : 'btn-ghost'}`}
+              style={{ textTransform: 'capitalize' }}
+            >
+              {s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Loading */}
+      {loading && (
+        <SkeletonList count={4} />
+      )}
+
+      {/* Empty State */}
+      {!loading && petitions.length === 0 && (
+        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+          <div
+            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+            style={{
+              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+              border: '1px solid rgba(124, 58, 237, 0.2)',
+            }}
+          >
+            <Megaphone size={28} style={{ color: 'var(--color-primary)' }} />
+          </div>
+          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+            No petitions yet
+          </h3>
+          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+            Start your first advocacy campaign. Use AI to generate a compelling petition from trending topics.
+          </p>
+          <button className="btn btn-primary" onClick={() => setShowAiModal(true)}>
+            <Sparkles size={16} />
+            AI Create Petition
+          </button>
+        </div>
+      )}
+
+      {/* Petition Cards */}
+      {!loading && sortedPetitions.length > 0 && (
+        <div className="flex flex-col gap-3">
+          {sortedPetitions.map((p) => {
+            const pct = sigPct(p.signature_count, p.signature_goal);
+            const isExpanded = expanded === p.id;
+            return (
+              <div key={p.id} className="card" style={{ overflow: 'hidden' }}>
+                {/* Top Row */}
+                <div className="flex items-start justify-between gap-4">
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2 mb-1 flex-wrap">
+                      <h3
+                        className="text-sm font-semibold truncate cursor-pointer"
+                        style={{ color: 'var(--color-text)' }}
+                        onClick={() => setExpanded(isExpanded ? null : p.id)}
+                      >
+                        {p.title}
+                      </h3>
+                      <span className={`badge ${statusBadgeClass[p.status] ?? 'badge-draft'}`}>
+                        {p.status}
+                      </span>
+                      {p.category && (
+                        <span
+                          className="badge"
+                          style={{
+                            backgroundColor: 'rgba(124, 58, 237, 0.1)',
+                            color: 'var(--color-secondary)',
+                            border: '1px solid rgba(124, 58, 237, 0.2)',
+                          }}
+                        >
+                          {categoryLabel(p.category)}
+                        </span>
+                      )}
+                    </div>
+
+                    {/* Signature Progress */}
+                    <div className="flex items-center gap-3 mt-2">
+                      <div
+                        style={{
+                          flex: 1,
+                          maxWidth: 200,
+                          height: 6,
+                          borderRadius: 3,
+                          backgroundColor: 'var(--color-surface-el)',
+                          overflow: 'hidden',
+                        }}
+                      >
+                        <div
+                          style={{
+                            width: `${pct}%`,
+                            height: '100%',
+                            borderRadius: 3,
+                            background: 'linear-gradient(90deg, #7c3aed, #a78bfa)',
+                            transition: 'width 0.3s ease',
+                          }}
+                        />
+                      </div>
+                      <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                        {p.signature_count}/{p.signature_goal} ({pct}%)
+                      </span>
+                    </div>
+
+                    {/* Meta Row */}
+                    <div className="flex items-center gap-4 mt-2">
+                      {p.target && (
+                        <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                          <Target size={12} /> {p.target}
+                        </span>
+                      )}
+                      <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                        <Share2 size={12} /> {p.share_count} shares
+                      </span>
+                      <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                        <Calendar size={12} /> {formatDate(p.created_at)}
+                      </span>
+                    </div>
+                  </div>
+
+                  {/* Actions */}
+                  <div className="flex items-center gap-1 shrink-0">
+                    {p.status === 'draft' && (
+                      <button className="btn btn-ghost btn-sm" onClick={() => openEdit(p)} title="Edit">
+                        <Edit3 size={14} />
+                      </button>
+                    )}
+                    <button
+                      className="btn btn-sm btn-primary"
+                      title="Deploy to Platforms"
+                      onClick={() => {
+                        setDeployPetition(p);
+                        setDeployPlatforms({ moveon: true, change_org: true, we_the_people: false, custom: false });
+                        setDeployResults([]);
+                        setDeployCustomUrl('');
+                        setShowDeployModal(true);
+                      }}
+                    >
+                      <Send size={14} />
+                      <span className="hidden sm:inline">Campaign</span>
+                    </button>
+                    <button
+                      className="btn btn-ghost btn-sm"
+                      onClick={() => setExpanded(isExpanded ? null : p.id)}
+                      title={isExpanded ? 'Collapse' : 'Expand'}
+                    >
+                      {isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+                    </button>
+                  </div>
+                </div>
+
+                {/* Expanded Details */}
+                {isExpanded && (
+                  <div
+                    style={{
+                      marginTop: 12,
+                      paddingTop: 12,
+                      borderTop: '1px solid var(--color-border)',
+                    }}
+                  >
+                    {p.summary && (
+                      <p className="text-sm mb-3" style={{ color: 'var(--color-text-secondary)' }}>
+                        {p.summary}
+                      </p>
+                    )}
+
+                    {/* Body Preview -- displayed as plain text for safety */}
+                    <div
+                      className="text-xs mb-3"
+                      style={{
+                        color: 'var(--color-text-muted)',
+                        maxHeight: 120,
+                        overflow: 'hidden',
+                        padding: 8,
+                        borderRadius: 6,
+                        backgroundColor: 'var(--color-surface-el)',
+                        whiteSpace: 'pre-wrap',
+                      }}
+                    >
+                      {stripHtml(p.body_html).slice(0, 500)}
+                    </div>
+
+                    {/* Target Emails */}
+                    {p.target_emails && p.target_emails.length > 0 && (
+                      <div className="flex items-center gap-2 mb-2 flex-wrap">
+                        <MailIcon size={12} style={{ color: 'var(--color-text-muted)' }} />
+                        {p.target_emails.map((em, i) => (
+                          <span key={i} className="text-xs" style={{
+                            color: 'var(--color-text-secondary)',
+                            padding: '2px 6px',
+                            borderRadius: 4,
+                            backgroundColor: 'var(--color-surface-el)',
+                          }}>
+                            {em}
+                          </span>
+                        ))}
+                      </div>
+                    )}
+
+                    {/* Tags */}
+                    {p.tags && p.tags.length > 0 && (
+                      <div className="flex items-center gap-1 flex-wrap mb-3">
+                        <Tag size={12} style={{ color: 'var(--color-text-muted)' }} />
+                        {p.tags.map((t, i) => (
+                          <span key={i} className="text-xs" style={{
+                            color: 'var(--color-secondary)',
+                            padding: '2px 8px',
+                            borderRadius: 9999,
+                            backgroundColor: 'rgba(124, 58, 237, 0.1)',
+                          }}>
+                            {t}
+                          </span>
+                        ))}
+                      </div>
+                    )}
+
+                    {/* Related Grants Section */}
+                    <div style={{ marginTop: 8 }}>
+                      {!petitionGrants[p.id] ? (
+                        <button
+                          onClick={() => findRelatedGrants(p.id)}
+                          disabled={loadingGrants === p.id}
+                          style={{
+                            display: 'flex', alignItems: 'center', gap: 5,
+                            padding: '6px 14px', borderRadius: 8,
+                            border: '1px solid #059669',
+                            backgroundColor: 'transparent', color: '#10b981',
+                            cursor: loadingGrants === p.id ? 'wait' : 'pointer',
+                            fontSize: 12, fontWeight: 600,
+                          }}
+                        >
+                          {loadingGrants === p.id
+                            ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
+                            : <><DollarSign size={12} /> Find Related Grants</>
+                          }
+                        </button>
+                      ) : petitionGrants[p.id].length > 0 ? (
+                        <div>
+                          <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+                            Related Grants ({petitionGrants[p.id].length})
+                          </div>
+                          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
+                            {petitionGrants[p.id].slice(0, 5).map(g => (
+                              <div key={g.id}
+                                onClick={() => onViewGrant?.(g.id)}
+                                style={{
+                                  display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
+                                  borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
+                                  cursor: 'pointer', transition: 'background-color 0.15s',
+                                }}
+                                onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#059669' + '18'; }}
+                                onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
+                              >
+                                <DollarSign size={12} style={{ color: '#10b981', flexShrink: 0 }} />
+                                <div style={{ flex: 1, minWidth: 0 }}>
+                                  <span style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text)' }}>{g.title}</span>
+                                  <span style={{ fontSize: 11, color: 'var(--color-text-muted)', marginLeft: 6 }}>
+                                    {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+                                  </span>
+                                </div>
+                                {g.ai_fit_score != null && (
+                                  <span style={{ fontSize: 10, fontWeight: 600, color: '#f59e0b' }}>
+                                    {Math.round(g.ai_fit_score * 100)}%
+                                  </span>
+                                )}
+                                <ExternalLink size={11} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+                              </div>
+                            ))}
+                          </div>
+                        </div>
+                      ) : (
+                        <div style={{ fontSize: 12, color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
+                          No related grants found yet. Try discovering grants first.
+                        </div>
+                      )}
+                    </div>
+                  </div>
+                )}
+              </div>
+            );
+          })}
+        </div>
+      )}
+
+      {/* ─── AI Create Modal ─────────────────────────────────────── */}
+      {showAiModal && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)',
+            backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setShowAiModal(false); }}
+        >
+          <div
+            style={{
+              width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+              backgroundColor: 'var(--color-surface)',
+              border: '1px solid var(--color-border)',
+              borderRadius: 12, padding: 24,
+            }}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+                <Sparkles size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
+                AI Create Petition
+              </h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setShowAiModal(false)}>
+                <X size={16} />
+              </button>
+            </div>
+
+            {!aiResult ? (
+              <div className="flex flex-col gap-4">
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+                    Topic *
+                  </label>
+                  <input
+                    className="input"
+                    placeholder="e.g., Student loan interest rate reduction for federal loans"
+                    value={aiTopic}
+                    onChange={(e) => setAiTopic(e.target.value)}
+                  />
+                </div>
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+                    Target (optional)
+                  </label>
+                  <input
+                    className="input"
+                    placeholder="e.g., Department of Education"
+                    value={aiTarget}
+                    onChange={(e) => setAiTarget(e.target.value)}
+                  />
+                </div>
+                <div className="grid grid-cols-2 gap-4">
+                  <div>
+                    <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+                      Tone
+                    </label>
+                    <select
+                      className="input"
+                      value={aiTone}
+                      onChange={(e) => setAiTone(e.target.value)}
+                    >
+                      <option value="urgent">Urgent</option>
+                      <option value="formal">Formal</option>
+                      <option value="passionate">Passionate</option>
+                      <option value="grassroots">Grassroots</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+                      Category
+                    </label>
+                    <select
+                      className="input"
+                      value={aiCategory}
+                      onChange={(e) => setAiCategory(e.target.value)}
+                    >
+                      <option value="debt_relief">Debt Relief</option>
+                      <option value="policy">Policy</option>
+                      <option value="education_access">Education Access</option>
+                      <option value="consumer_protection">Consumer Protection</option>
+                      <option value="federal_budget">Federal Budget</option>
+                    </select>
+                  </div>
+                </div>
+                <button
+                  className="btn btn-primary w-full"
+                  onClick={handleAiGenerate}
+                  disabled={aiGenerating || !aiTopic.trim()}
+                >
+                  {aiGenerating ? (
+                    <>
+                      <Loader2 size={16} className="animate-spin" />
+                      Generating...
+                    </>
+                  ) : (
+                    <>
+                      <Sparkles size={16} />
+                      Generate Petition
+                    </>
+                  )}
+                </button>
+              </div>
+            ) : (
+              /* ── AI Result Preview ─── */
+              <div className="flex flex-col gap-4">
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Title</div>
+                  <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{aiResult.title}</div>
+                </div>
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Summary</div>
+                  <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.summary}</div>
+                </div>
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Target</div>
+                  <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{aiResult.suggested_target}</div>
+                </div>
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Body Preview</div>
+                  <div
+                    className="text-xs"
+                    style={{
+                      color: 'var(--color-text-muted)',
+                      maxHeight: 200, overflow: 'auto',
+                      padding: 8, borderRadius: 6,
+                      backgroundColor: 'var(--color-surface-el)',
+                      whiteSpace: 'pre-wrap',
+                    }}
+                  >
+                    {stripHtml(aiResult.body_html)}
+                  </div>
+                </div>
+                <div className="flex items-center gap-2 flex-wrap">
+                  <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>Tags:</span>
+                  {aiResult.suggested_tags.map((t, i) => (
+                    <span key={i} className="text-xs" style={{
+                      color: 'var(--color-secondary)',
+                      padding: '2px 8px', borderRadius: 9999,
+                      backgroundColor: 'rgba(124, 58, 237, 0.1)',
+                    }}>
+                      {t}
+                    </span>
+                  ))}
+                </div>
+                <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                  Suggested Goal: {aiResult.suggested_goal} signatures
+                </div>
+                <div className="flex items-center gap-3">
+                  <button className="btn btn-primary flex-1" onClick={handleSaveDraft} disabled={savingDraft}>
+                    {savingDraft ? (
+                      <><Loader2 size={16} className="animate-spin" /> Saving...</>
+                    ) : (
+                      <><Plus size={16} /> Save as Draft</>
+                    )}
+                  </button>
+                  <button className="btn btn-ghost" onClick={() => setAiResult(null)}>
+                    Regenerate
+                  </button>
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+
+      {/* ─── Edit Modal ──────────────────────────────────────────── */}
+      {editPetition && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)',
+            backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setEditPetition(null); }}
+        >
+          <div
+            style={{
+              width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+              backgroundColor: 'var(--color-surface)',
+              border: '1px solid var(--color-border)',
+              borderRadius: 12, padding: 24,
+            }}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+                <Edit3 size={16} style={{ display: 'inline', marginRight: 6 }} />
+                Edit Petition
+              </h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setEditPetition(null)}>
+                <X size={16} />
+              </button>
+            </div>
+            <div className="flex flex-col gap-4">
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title</label>
+                <input className="input" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Summary</label>
+                <textarea
+                  className="input"
+                  rows={3}
+                  value={editSummary}
+                  onChange={(e) => setEditSummary(e.target.value)}
+                  style={{ resize: 'vertical' }}
+                />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+                <input className="input" value={editTarget} onChange={(e) => setEditTarget(e.target.value)} />
+              </div>
+              <div className="grid grid-cols-2 gap-4">
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
+                  <input className="input" type="number" value={editGoal} onChange={(e) => setEditGoal(Number(e.target.value))} />
+                </div>
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Status</label>
+                  <select className="input" value={editStatus} onChange={(e) => setEditStatus(e.target.value)}>
+                    <option value="draft">Draft</option>
+                    <option value="active">Active</option>
+                    <option value="paused">Paused</option>
+                    <option value="closed">Closed</option>
+                    <option value="delivered">Delivered</option>
+                  </select>
+                </div>
+              </div>
+              <button className="btn btn-primary w-full" onClick={handleSaveEdit} disabled={editSaving}>
+                {editSaving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : 'Save Changes'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* ─── Deploy Modal ──────────────────────────────────────────── */}
+      {showDeployModal && deployPetition && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)',
+            backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) { setShowDeployModal(false); setDeployResults([]); } }}
+        >
+          <div
+            style={{
+              width: '100%', maxWidth: 640, maxHeight: '90vh', overflow: 'auto',
+              backgroundColor: 'var(--color-surface)',
+              border: '1px solid var(--color-border)',
+              borderRadius: 12, padding: 24,
+            }}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+                <Send size={16} style={{ display: 'inline', marginRight: 6, color: 'var(--color-primary)' }} />
+                Deploy to Platforms
+              </h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => { setShowDeployModal(false); setDeployResults([]); }}>
+                <X size={16} />
+              </button>
+            </div>
+
+            {/* Petition Info */}
+            <div style={{ padding: '10px 14px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', marginBottom: 16 }}>
+              <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{deployPetition.title}</div>
+              <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+                {categoryLabel(deployPetition.category)} · {deployPetition.signature_count} signatures · {deployPetition.status}
+              </div>
+            </div>
+
+            {/* Platform Checkboxes */}
+            {deployResults.length === 0 && (
+              <>
+                <div className="text-xs font-medium mb-2" style={{ color: 'var(--color-text-secondary)' }}>Select Platforms:</div>
+                <div className="flex flex-col gap-2 mb-4">
+                  {DEPLOY_PLATFORMS.map(plat => (
+                    <label
+                      key={plat.key}
+                      style={{
+                        display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
+                        borderRadius: 8, cursor: 'pointer',
+                        border: `1px solid ${deployPlatforms[plat.key] ? 'var(--color-primary)' : 'var(--color-border)'}`,
+                        backgroundColor: deployPlatforms[plat.key] ? 'rgba(124, 58, 237, 0.08)' : 'transparent',
+                        transition: 'all 0.15s ease',
+                      }}
+                    >
+                      <input
+                        type="checkbox"
+                        checked={!!deployPlatforms[plat.key]}
+                        onChange={(e) => setDeployPlatforms(prev => ({ ...prev, [plat.key]: e.target.checked }))}
+                        style={{ accentColor: 'var(--color-primary)', width: 16, height: 16 }}
+                      />
+                      <span style={{ fontSize: 18 }}>{plat.icon}</span>
+                      <div style={{ flex: 1 }}>
+                        <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{plat.name}</div>
+                        {plat.url && <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{plat.url}</div>}
+                      </div>
+                    </label>
+                  ))}
+                  {deployPlatforms.custom && (
+                    <input
+                      className="input mt-1"
+                      placeholder="Enter custom platform URL..."
+                      value={deployCustomUrl}
+                      onChange={(e) => setDeployCustomUrl(e.target.value)}
+                    />
+                  )}
+                </div>
+
+                {/* Select/Clear Buttons */}
+                <div className="flex items-center gap-2 mb-4">
+                  <button
+                    className="btn btn-ghost btn-sm"
+                    onClick={() => setDeployPlatforms({ moveon: true, change_org: true, we_the_people: true, custom: true })}
+                  >
+                    Select All
+                  </button>
+                  <button
+                    className="btn btn-ghost btn-sm"
+                    onClick={() => setDeployPlatforms({ moveon: false, change_org: false, we_the_people: false, custom: false })}
+                  >
+                    Clear All
+                  </button>
+                </div>
+
+                {/* Deploy Button */}
+                <button
+                  className="btn btn-primary w-full"
+                  onClick={handleDeploy}
+                  disabled={deploying || !Object.values(deployPlatforms).some(Boolean)}
+                >
+                  {deploying ? (
+                    <><Loader2 size={16} className="animate-spin" /> Generating...</>
+                  ) : (
+                    <><Send size={16} /> Generate &amp; Deploy</>
+                  )}
+                </button>
+              </>
+            )}
+
+            {/* Deploy Results */}
+            {deployResults.length > 0 && (
+              <div className="flex flex-col gap-4">
+                <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
+                  Results ({deployResults.length} platform{deployResults.length > 1 ? 's' : ''})
+                </div>
+                {deployResults.map((r) => (
+                  <div key={r.platform} style={{ borderRadius: 8, border: '1px solid var(--color-border)', overflow: 'hidden' }}>
+                    <div style={{ padding: '10px 14px', backgroundColor: 'rgba(124, 58, 237, 0.08)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+                      <div className="flex items-center gap-2">
+                        <span style={{ color: '#10b981', fontSize: 14 }}>✓</span>
+                        <span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>{r.platform_name}</span>
+                        <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>— Content ready</span>
+                      </div>
+                      <button
+                        className="btn btn-sm btn-primary"
+                        onClick={() => window.open(r.creation_url, '_blank')}
+                      >
+                        <ExternalLink size={12} /> Open Platform
+                      </button>
+                    </div>
+                    <div style={{ padding: 14 }}>
+                      {Object.entries(r.formatted_content).map(([key, value]) => (
+                        <div key={key} style={{ marginBottom: 8 }}>
+                          <div className="flex items-center justify-between mb-1">
+                            <span className="text-xs font-medium" style={{ color: 'var(--color-text-muted)', textTransform: 'capitalize' }}>
+                              {key.replace(/_/g, ' ')}
+                            </span>
+                            <button
+                              className="text-xs"
+                              style={{
+                                padding: '2px 8px', borderRadius: 4, cursor: 'pointer',
+                                border: '1px solid var(--color-border)',
+                                backgroundColor: copiedField === `${r.platform}-${key}` ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
+                                color: copiedField === `${r.platform}-${key}` ? '#10b981' : 'var(--color-text-muted)',
+                                transition: 'all 0.15s',
+                              }}
+                              onClick={() => copyToClipboard(
+                                Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? ''),
+                                `${r.platform}-${key}`
+                              )}
+                            >
+                              {copiedField === `${r.platform}-${key}` ? 'Copied!' : 'Copy'}
+                            </button>
+                          </div>
+                          <div
+                            className="text-xs"
+                            style={{
+                              color: 'var(--color-text-secondary)',
+                              padding: '6px 8px', borderRadius: 4,
+                              backgroundColor: 'var(--color-surface-el)',
+                              maxHeight: 80, overflow: 'auto',
+                              whiteSpace: 'pre-wrap', wordBreak: 'break-word',
+                            }}
+                          >
+                            {Array.isArray(value) ? (value as string[]).join(', ') : String(value ?? '—')}
+                          </div>
+                        </div>
+                      ))}
+                    </div>
+                  </div>
+                ))}
+                <button
+                  className="btn btn-ghost w-full"
+                  onClick={() => setDeployResults([])}
+                >
+                  ← Back to Platform Selection
+                </button>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/settings/SettingsTab.tsx b/components/settings/SettingsTab.tsx
new file mode 100644
index 0000000..c7da366
--- /dev/null
+++ b/components/settings/SettingsTab.tsx
@@ -0,0 +1,413 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+  Save, RefreshCw, Building2, Mail, Sparkles, Database,
+  Globe, FileText, Clock, Shield, Download, Megaphone,
+  Users, Target, Settings, Share2,
+} from 'lucide-react';
+import SocialCredentialsSection from './SocialCredentialsSection';
+
+/* ─── Types ──────────────────────────────────────────────────────────────── */
+interface OrgProfile {
+  name: string;
+  website: string;
+  email: string;
+  mission: string;
+}
+
+interface PetitionDefaults {
+  default_signature_goal: number;
+  default_distribution: string;
+  auto_compliance_check: boolean;
+}
+
+interface CampaignSettings {
+  default_send_time: string;
+  email_footer: string;
+  unsubscribe_text: string;
+}
+
+interface AIConfig {
+  model: string;
+  creativity_level: number;
+  default_tone: string;
+}
+
+interface DataStats {
+  total_petitions: number;
+  total_signatures: number;
+  total_subscribers: number;
+  total_campaigns: number;
+  last_backup: string;
+}
+
+interface SettingsData {
+  org_profile: OrgProfile;
+  petition_defaults: PetitionDefaults;
+  campaign_settings: CampaignSettings;
+  ai_config: AIConfig;
+  data_stats: DataStats;
+}
+
+/* ─── Defaults ───────────────────────────────────────────────────────────── */
+const DEFAULTS: SettingsData = {
+  org_profile: {
+    name: 'Student Debt Crisis Center',
+    website: 'https://studentdebtcrisis.org',
+    email: 'info@studentdebtcrisis.org',
+    mission: 'Empowering borrowers through organized petition campaigns and advocacy for systemic student debt reform.',
+  },
+  petition_defaults: {
+    default_signature_goal: 10000,
+    default_distribution: 'email',
+    auto_compliance_check: true,
+  },
+  campaign_settings: {
+    default_send_time: '09:00',
+    email_footer: 'Student Debt Crisis Center | studentdebtcrisis.org',
+    unsubscribe_text: 'Click here to unsubscribe from future petition updates.',
+  },
+  ai_config: {
+    model: 'Gemini Flash',
+    creativity_level: 60,
+    default_tone: 'passionate',
+  },
+  data_stats: {
+    total_petitions: 0,
+    total_signatures: 0,
+    total_subscribers: 0,
+    total_campaigns: 0,
+    last_backup: new Date().toISOString(),
+  },
+};
+
+/* ─── Main Component ─────────────────────────────────────────────────────── */
+export default function SettingsTab() {
+  const [settings, setSettings] = useState<SettingsData>(DEFAULTS);
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+  const [saveMessage, setSaveMessage] = useState('');
+
+  useEffect(() => {
+    fetchSettings();
+  }, []);
+
+  async function fetchSettings() {
+    try {
+      const res = await fetch('/api/settings');
+      if (!res.ok) throw new Error('Failed to fetch');
+      const data = await res.json();
+      const s = data.settings || {};
+      setSettings({
+        org_profile: { ...DEFAULTS.org_profile, ...(s.org_profile || {}) },
+        petition_defaults: { ...DEFAULTS.petition_defaults, ...(s.petition_defaults || {}) },
+        campaign_settings: { ...DEFAULTS.campaign_settings, ...(s.campaign_settings || {}) },
+        ai_config: { ...DEFAULTS.ai_config, ...(s.ai_config || {}) },
+        data_stats: { ...DEFAULTS.data_stats, ...(s.data_stats || {}) },
+      });
+    } catch (err) {
+      console.error('Failed to fetch settings:', err);
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  async function handleSave() {
+    setSaving(true);
+    setSaveMessage('');
+    try {
+      const res = await fetch('/api/settings', {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          org_profile: settings.org_profile,
+          petition_defaults: settings.petition_defaults,
+          campaign_settings: settings.campaign_settings,
+          ai_config: settings.ai_config,
+        }),
+      });
+      if (!res.ok) throw new Error('Failed to save');
+      setSaveMessage('Settings saved successfully');
+      setTimeout(() => setSaveMessage(''), 3000);
+    } catch (err) {
+      setSaveMessage('Failed to save settings');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  function updateOrg(field: string, value: string) {
+    setSettings((prev) => ({
+      ...prev,
+      org_profile: { ...prev.org_profile, [field]: value },
+    }));
+  }
+
+  function updatePetition(field: string, value: string | number | boolean) {
+    setSettings((prev) => ({
+      ...prev,
+      petition_defaults: { ...prev.petition_defaults, [field]: value },
+    }));
+  }
+
+  function updateCampaign(field: string, value: string) {
+    setSettings((prev) => ({
+      ...prev,
+      campaign_settings: { ...prev.campaign_settings, [field]: value },
+    }));
+  }
+
+  function updateAI(field: string, value: string | number) {
+    setSettings((prev) => ({
+      ...prev,
+      ai_config: { ...prev.ai_config, [field]: value },
+    }));
+  }
+
+  if (loading) {
+    return (
+      <div className="flex items-center justify-center py-20">
+        <RefreshCw size={20} className="animate-spin" style={{ color: 'var(--color-text-muted)' }} />
+      </div>
+    );
+  }
+
+  return (
+    <div className="p-5 space-y-5 max-w-4xl">
+      {/* Save bar */}
+      <div className="flex items-center justify-between">
+        <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Settings</h2>
+        <div className="flex items-center gap-3">
+          {saveMessage && (
+            <span className="text-sm" style={{ color: saveMessage.includes('success') ? '#34d399' : '#f87171' }}>
+              {saveMessage}
+            </span>
+          )}
+          <button className="btn btn-primary" onClick={handleSave} disabled={saving}>
+            {saving ? (
+              <><RefreshCw size={14} className="animate-spin" /><span>Saving...</span></>
+            ) : (
+              <><Save size={14} /><span>Save Changes</span></>
+            )}
+          </button>
+        </div>
+      </div>
+
+      {/* ── Organization Profile ─────────────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-4">
+          <Building2 size={16} style={{ color: '#7c3aed' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Organization Profile</h3>
+        </div>
+
+        <div className="grid grid-cols-2 gap-4">
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Organization Name</label>
+            <input className="input w-full" value={settings.org_profile.name} onChange={(e) => updateOrg('name', e.target.value)} />
+          </div>
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Website URL</label>
+            <div className="relative">
+              <Globe size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+              <input className="input w-full pl-8" value={settings.org_profile.website} onChange={(e) => updateOrg('website', e.target.value)} />
+            </div>
+          </div>
+          <div className="col-span-2">
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Contact Email</label>
+            <div className="relative">
+              <Mail size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+              <input className="input w-full pl-8" value={settings.org_profile.email} onChange={(e) => updateOrg('email', e.target.value)} />
+            </div>
+          </div>
+        </div>
+
+        <div className="mt-4">
+          <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Mission Statement</label>
+          <textarea className="input w-full" rows={3} value={settings.org_profile.mission} onChange={(e) => updateOrg('mission', e.target.value)} />
+        </div>
+      </div>
+
+      {/* ── Petition Defaults ────────────────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-4">
+          <Megaphone size={16} style={{ color: '#f59e0b' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Petition Defaults</h3>
+        </div>
+
+        <div className="grid grid-cols-2 gap-4">
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Signature Goal</label>
+            <div className="relative">
+              <Target size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--color-text-muted)' }} />
+              <input
+                type="number"
+                className="input w-full pl-8"
+                value={settings.petition_defaults.default_signature_goal}
+                onChange={(e) => updatePetition('default_signature_goal', parseInt(e.target.value) || 0)}
+              />
+            </div>
+          </div>
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Distribution</label>
+            <select
+              className="input w-full"
+              value={settings.petition_defaults.default_distribution}
+              onChange={(e) => updatePetition('default_distribution', e.target.value)}
+            >
+              <option value="email">Email</option>
+              <option value="social">Social Media</option>
+              <option value="both">Both</option>
+            </select>
+          </div>
+        </div>
+
+        <div className="mt-4">
+          <label className="flex items-center gap-2 cursor-pointer">
+            <input
+              type="checkbox"
+              checked={settings.petition_defaults.auto_compliance_check}
+              onChange={(e) => updatePetition('auto_compliance_check', e.target.checked)}
+            />
+            <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+              <Shield size={12} className="inline mr-1" style={{ verticalAlign: 'middle', color: '#10b981' }} />
+              Auto-compliance check on new petitions
+            </span>
+          </label>
+        </div>
+      </div>
+
+      {/* ── Campaign Settings ────────────────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-4">
+          <Mail size={16} style={{ color: '#06b6d4' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Settings</h3>
+        </div>
+
+        <div className="grid grid-cols-2 gap-4">
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Send Time</label>
+            <input
+              type="time"
+              className="input w-full"
+              value={settings.campaign_settings.default_send_time}
+              onChange={(e) => updateCampaign('default_send_time', e.target.value)}
+            />
+          </div>
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email Footer</label>
+            <input className="input w-full" value={settings.campaign_settings.email_footer} onChange={(e) => updateCampaign('email_footer', e.target.value)} />
+          </div>
+        </div>
+
+        <div className="mt-4">
+          <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Unsubscribe Text</label>
+          <textarea
+            className="input w-full"
+            rows={2}
+            value={settings.campaign_settings.unsubscribe_text}
+            onChange={(e) => updateCampaign('unsubscribe_text', e.target.value)}
+          />
+        </div>
+      </div>
+
+      {/* ── AI Configuration ─────────────────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-4">
+          <Sparkles size={16} style={{ color: '#f59e0b' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>AI Configuration</h3>
+        </div>
+
+        <div className="grid grid-cols-2 gap-4">
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>AI Model</label>
+            <input className="input w-full" value={settings.ai_config.model} readOnly style={{ opacity: 0.7, cursor: 'not-allowed' }} />
+          </div>
+          <div>
+            <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Default Tone</label>
+            <select className="input w-full" value={settings.ai_config.default_tone} onChange={(e) => updateAI('default_tone', e.target.value)}>
+              <option value="formal">Formal</option>
+              <option value="passionate">Passionate</option>
+              <option value="urgent">Urgent</option>
+              <option value="conversational">Conversational</option>
+            </select>
+          </div>
+        </div>
+
+        <div className="mt-4">
+          <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+            Creativity Level: {settings.ai_config.creativity_level}%
+          </label>
+          <input
+            type="range"
+            min={0}
+            max={100}
+            value={settings.ai_config.creativity_level}
+            onChange={(e) => updateAI('creativity_level', parseInt(e.target.value))}
+            className="w-full"
+            style={{ accentColor: 'var(--color-primary)' }}
+          />
+          <div className="flex justify-between text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            <span>Conservative</span>
+            <span>Creative</span>
+          </div>
+        </div>
+      </div>
+
+      {/* ── Data Stats ───────────────────────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-4">
+          <Database size={16} style={{ color: '#10b981' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Data Management</h3>
+        </div>
+
+        <div className="grid grid-cols-4 gap-3 mb-4">
+          {[
+            { label: 'Petitions', value: settings.data_stats.total_petitions, icon: Megaphone },
+            { label: 'Signatures', value: settings.data_stats.total_signatures, icon: FileText },
+            { label: 'Subscribers', value: settings.data_stats.total_subscribers, icon: Users },
+            { label: 'Campaigns', value: settings.data_stats.total_campaigns, icon: Mail },
+          ].map((stat) => (
+            <div
+              key={stat.label}
+              className="rounded-lg p-3 text-center"
+              style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
+            >
+              <stat.icon size={16} className="mx-auto mb-1" style={{ color: 'var(--color-text-muted)' }} />
+              <p className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</p>
+              <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</p>
+            </div>
+          ))}
+        </div>
+
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-text-muted)' }}>
+            <Clock size={12} />
+            <span>
+              Last backup: {new Date(settings.data_stats.last_backup).toLocaleString('en-US', {
+                timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric',
+                hour: 'numeric', minute: '2-digit', hour12: true,
+              })} PT
+            </span>
+          </div>
+          <button className="btn btn-ghost btn-sm">
+            <Download size={13} />
+            <span>Export All Data</span>
+          </button>
+        </div>
+      </div>
+
+      {/* ── Social Platform Credentials ───────────────────────────────────── */}
+      <div className="card p-5">
+        <div className="flex items-center gap-2 mb-1">
+          <Share2 size={16} style={{ color: '#8b5cf6' }} />
+          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>Social Platform Credentials</h3>
+        </div>
+        <p className="text-xs mb-4" style={{ color: 'var(--color-text-muted)' }}>
+          Configure API credentials for each platform. Credentials are saved to the agent servers and take effect immediately on save.
+        </p>
+        <SocialCredentialsSection />
+      </div>
+    </div>
+  );
+}
diff --git a/components/settings/SocialCredentialsSection.tsx b/components/settings/SocialCredentialsSection.tsx
new file mode 100644
index 0000000..ebe3cc0
--- /dev/null
+++ b/components/settings/SocialCredentialsSection.tsx
@@ -0,0 +1,420 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Eye, EyeOff, Save, RefreshCw, CheckCircle2, XCircle, AlertTriangle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
+
+/* ─── Platform definitions ──────────────────────────────────────────────── */
+interface FieldDef {
+  key: string;
+  label: string;
+  placeholder: string;
+  help?: string;
+}
+
+interface PlatformDef {
+  id: string;
+  label: string;
+  color: string;
+  icon: string;
+  docsUrl: string;
+  instructions: { step: string; detail: string }[];
+  fields: FieldDef[];
+  note?: string;
+}
+
+const PLATFORMS: PlatformDef[] = [
+  {
+    id: 'twitter',
+    label: 'X / Twitter',
+    color: '#1DA1F2',
+    icon: '𝕏',
+    docsUrl: 'https://developer.twitter.com/en/portal/dashboard',
+    instructions: [
+      { step: 'Go to developer.twitter.com', detail: 'Sign in with your Twitter/X account and open the Developer Portal.' },
+      { step: 'Create a Project + App', detail: 'Click "+ New Project", name it, then create an App inside it. Choose "Production" environment.' },
+      { step: 'Set app permissions', detail: 'In App Settings → User authentication settings, enable OAuth 1.0a with Read & Write permissions.' },
+      { step: 'Get API Key & Secret', detail: 'In "Keys and Tokens" → Consumer Keys section. Copy API Key and API Key Secret.' },
+      { step: 'Get Access Token & Secret', detail: 'Still in Keys and Tokens → Authentication Tokens section. Click "Generate" to get Access Token and Access Token Secret.' },
+      { step: 'Get Bearer Token', detail: 'In the same Keys and Tokens page, copy the Bearer Token (used for read-only operations).' },
+    ],
+    fields: [
+      { key: 'TWITTER_API_KEY', label: 'API Key (Consumer Key)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'TWITTER_API_SECRET', label: 'API Key Secret (Consumer Secret)', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'TWITTER_ACCESS_TOKEN', label: 'Access Token', placeholder: '000000000-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'TWITTER_ACCESS_SECRET', label: 'Access Token Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'TWITTER_BEARER_TOKEN', label: 'Bearer Token (for reading)', placeholder: 'AAAAAAAAAAAAAAAAAAAAAxxxxxxxxxxxxxxxx', help: 'Optional — only needed for search/discover features' },
+    ],
+  },
+  {
+    id: 'reddit',
+    label: 'Reddit',
+    color: '#FF4500',
+    icon: '🤖',
+    docsUrl: 'https://www.reddit.com/prefs/apps',
+    instructions: [
+      { step: 'Go to reddit.com/prefs/apps', detail: 'Log in to the Reddit account that will post. Go to Preferences → Apps.' },
+      { step: 'Create an app', detail: 'Click "create another app..." at the bottom. Choose type "script" (for personal use).' },
+      { step: 'Fill in the form', detail: 'Name: anything (e.g. "PetitionBot"). Redirect URI: http://localhost:8080. Click "create app".' },
+      { step: 'Get Client ID', detail: 'The Client ID is the short string directly under the app name (below "personal use script").' },
+      { step: 'Get Client Secret', detail: 'The Client Secret is labeled "secret" in the app details.' },
+      { step: 'Username & Password', detail: 'These are the Reddit account credentials that will be posting. The account must be at least 30 days old to post in most subreddits.' },
+    ],
+    fields: [
+      { key: 'REDDIT_CLIENT_ID', label: 'Client ID', placeholder: 'xxxxxxxxxxxxxx' },
+      { key: 'REDDIT_CLIENT_SECRET', label: 'Client Secret', placeholder: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'REDDIT_USERNAME', label: 'Reddit Username', placeholder: 'u/your_username (without u/)' },
+      { key: 'REDDIT_PASSWORD', label: 'Reddit Password', placeholder: 'your reddit account password' },
+    ],
+  },
+  {
+    id: 'discord',
+    label: 'Discord',
+    color: '#5865F2',
+    icon: '💬',
+    docsUrl: 'https://discord.com/developers/applications',
+    instructions: [
+      { step: 'Go to discord.com/developers/applications', detail: 'Sign in with a Discord account that has admin access to your server.' },
+      { step: 'New Application', detail: 'Click "New Application", give it a name (e.g. "PetitionBot"). Click Create.' },
+      { step: 'Create a Bot', detail: 'In the left sidebar, click "Bot". Click "Add Bot" then confirm. This creates the bot user.' },
+      { step: 'Get Bot Token', detail: 'Click "Reset Token" to reveal the bot token. Copy it — you won\'t see it again without resetting.' },
+      { step: 'Set bot permissions', detail: 'Under "Privileged Gateway Intents", enable Message Content Intent. Also enable Server Members Intent.' },
+      { step: 'Invite bot to server', detail: 'Go to OAuth2 → URL Generator. Select scopes: bot. Permissions: Send Messages, Embed Links. Copy and open the URL to add bot to your server.' },
+      { step: 'Get Channel ID', detail: 'In Discord, enable Developer Mode (User Settings → Advanced). Right-click any channel → "Copy Channel ID".' },
+    ],
+    fields: [
+      { key: 'DISCORD_BOT_TOKEN', label: 'Bot Token', placeholder: 'MTxxxxxxxxxx.xxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxx' },
+      { key: 'DISCORD_DEFAULT_CHANNEL_ID', label: 'Default Channel ID', placeholder: '000000000000000000', help: 'Right-click a channel in Discord → Copy Channel ID (requires Developer Mode enabled)' },
+    ],
+  },
+  {
+    id: 'bluesky',
+    label: 'Bluesky',
+    color: '#0085FF',
+    icon: '🦋',
+    docsUrl: 'https://bsky.app/settings/app-passwords',
+    instructions: [
+      { step: 'Go to bsky.app/settings/app-passwords', detail: 'Sign in to Bluesky and navigate to Settings → App Passwords.' },
+      { step: 'Add App Password', detail: 'Click "+ Add App Password". Give it a name like "PetitionBot". Click "Create App Password".' },
+      { step: 'Copy the password', detail: 'The app password is shown only once. Copy it immediately — it looks like: xxxx-xxxx-xxxx-xxxx' },
+      { step: 'Your handle', detail: 'Your Bluesky handle is shown at bsky.app/profile. Format: yourname.bsky.social (include .bsky.social)' },
+    ],
+    fields: [
+      { key: 'BSKY_HANDLE', label: 'Bluesky Handle', placeholder: 'yourname.bsky.social', help: 'Include the full domain, e.g. yourorg.bsky.social' },
+      { key: 'BSKY_PASSWORD', label: 'App Password', placeholder: 'xxxx-xxxx-xxxx-xxxx', help: 'Use an App Password, NOT your main account password' },
+    ],
+    note: 'Use an App Password — never your main account password. App Passwords have limited scope and can be revoked independently.',
+  },
+];
+
+/* ─── Helper: masked input ────────────────────────────────────────────────── */
+function SecretInput({
+  value, onChange, placeholder,
+}: { value: string; onChange: (v: string) => void; placeholder: string }) {
+  const [show, setShow] = useState(false);
+  const isMasked = value.includes('*');
+
+  return (
+    <div className="relative">
+      <input
+        type={show ? 'text' : 'password'}
+        className="input w-full pr-8 font-mono text-xs"
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        placeholder={placeholder}
+        style={{ letterSpacing: show ? 'normal' : undefined }}
+        onFocus={() => { if (isMasked) onChange(''); }}
+      />
+      <button
+        type="button"
+        onClick={() => setShow((s) => !s)}
+        className="absolute right-2 top-1/2 -translate-y-1/2"
+        style={{ color: 'var(--color-text-muted)' }}
+      >
+        {show ? <EyeOff size={13} /> : <Eye size={13} />}
+      </button>
+    </div>
+  );
+}
+
+/* ─── Platform card ──────────────────────────────────────────────────────── */
+function PlatformCard({
+  platform,
+  initialValues,
+  onSaved,
+}: {
+  platform: PlatformDef;
+  initialValues: Record<string, string>;
+  onSaved: () => void;
+}) {
+  const [expanded, setExpanded] = useState(false);
+  const [showInstructions, setShowInstructions] = useState(false);
+  const [values, setValues] = useState<Record<string, string>>(() => {
+    const v: Record<string, string> = {};
+    for (const f of platform.fields) v[f.key] = initialValues[f.key] || '';
+    return v;
+  });
+  const [saving, setSaving] = useState(false);
+  const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
+  const [statusMsg, setStatusMsg] = useState('');
+
+  const isConfigured = platform.fields.some((f) => values[f.key] && !values[f.key].includes('*'));
+  const hasAnyMasked = platform.fields.some((f) => initialValues[f.key]?.includes('*'));
+
+  async function handleSave() {
+    setSaving(true);
+    try {
+      const res = await fetch('/api/settings/credentials', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ platform: platform.id, credentials: values }),
+      });
+      const data = await res.json();
+      if (!res.ok) throw new Error(data.error || 'Save failed');
+      setStatus('saved');
+      setStatusMsg(data.restarted ? '✓ Saved & agent restarted' : '✓ Saved (agent restart failed — restart manually)');
+      onSaved();
+    } catch (err) {
+      setStatus('error');
+      setStatusMsg((err as Error).message);
+    } finally {
+      setSaving(false);
+      setTimeout(() => setStatus('idle'), 4000);
+    }
+  }
+
+  return (
+    <div
+      className="rounded-lg overflow-hidden"
+      style={{ border: `1px solid ${expanded ? platform.color + '44' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
+    >
+      {/* Header */}
+      <button
+        className="w-full flex items-center gap-3 px-4 py-3 text-left"
+        style={{ background: expanded ? `${platform.color}0d` : 'transparent' }}
+        onClick={() => setExpanded((e) => !e)}
+      >
+        <span className="text-base w-6 text-center">{platform.icon}</span>
+        <span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>{platform.label}</span>
+
+        {/* Status badge */}
+        {hasAnyMasked ? (
+          <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(34,197,94,0.12)', color: '#22c55e' }}>
+            <CheckCircle2 size={10} /> Configured
+          </span>
+        ) : (
+          <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(245,158,11,0.12)', color: '#f59e0b' }}>
+            <AlertTriangle size={10} /> Not configured
+          </span>
+        )}
+
+        {expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
+      </button>
+
+      {expanded && (
+        <div className="px-4 pb-4 space-y-4" style={{ borderTop: `1px solid ${platform.color}22` }}>
+
+          {/* Instructions toggle */}
+          <div className="mt-3">
+            <button
+              className="flex items-center gap-2 text-xs font-medium"
+              style={{ color: platform.color }}
+              onClick={() => setShowInstructions((s) => !s)}
+            >
+              {showInstructions ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              How to get these credentials
+              <a
+                href={platform.docsUrl}
+                target="_blank"
+                rel="noreferrer"
+                onClick={(e) => e.stopPropagation()}
+                className="ml-1 flex items-center gap-1 opacity-60 hover:opacity-100"
+              >
+                <ExternalLink size={10} />
+                Open {platform.label}
+              </a>
+            </button>
+
+            {showInstructions && (
+              <ol className="mt-2 space-y-2">
+                {platform.instructions.map((ins, i) => (
+                  <li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+                    <span
+                      className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5"
+                      style={{ background: `${platform.color}22`, color: platform.color }}
+                    >
+                      {i + 1}
+                    </span>
+                    <div>
+                      <span className="font-semibold" style={{ color: 'var(--color-text)' }}>{ins.step}</span>
+                      <span className="text-xs ml-1" style={{ color: 'var(--color-text-muted)' }}>— {ins.detail}</span>
+                    </div>
+                  </li>
+                ))}
+              </ol>
+            )}
+          </div>
+
+          {/* Platform note */}
+          {platform.note && (
+            <div
+              className="text-xs px-3 py-2 rounded"
+              style={{ background: `${platform.color}10`, color: platform.color, border: `1px solid ${platform.color}30` }}
+            >
+              ⚠ {platform.note}
+            </div>
+          )}
+
+          {/* Credential fields */}
+          <div className="space-y-3">
+            {platform.fields.map((field) => (
+              <div key={field.key}>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
+                  {field.label}
+                </label>
+                <SecretInput
+                  value={values[field.key] || ''}
+                  onChange={(v) => setValues((prev) => ({ ...prev, [field.key]: v }))}
+                  placeholder={field.placeholder}
+                />
+                {field.help && (
+                  <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>{field.help}</p>
+                )}
+              </div>
+            ))}
+          </div>
+
+          {/* Save button + status */}
+          <div className="flex items-center gap-3 pt-1">
+            <button
+              className="btn btn-primary btn-sm"
+              onClick={handleSave}
+              disabled={saving}
+              style={{ background: platform.color, borderColor: platform.color }}
+            >
+              {saving ? (
+                <><RefreshCw size={12} className="animate-spin" /><span>Saving...</span></>
+              ) : (
+                <><Save size={12} /><span>Save & Apply to Agent</span></>
+              )}
+            </button>
+            {status !== 'idle' && (
+              <span
+                className="text-xs flex items-center gap-1"
+                style={{ color: status === 'saved' ? '#22c55e' : '#ef4444' }}
+              >
+                {status === 'saved' ? <CheckCircle2 size={12} /> : <XCircle size={12} />}
+                {statusMsg}
+              </span>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+/* ─── MoveOn card (no credentials — instructions only) ───────────────────── */
+function MoveOnCard() {
+  const [expanded, setExpanded] = useState(false);
+
+  return (
+    <div
+      className="rounded-lg overflow-hidden"
+      style={{ border: `1px solid ${expanded ? '#E0313144' : 'var(--color-border)'}`, background: 'var(--color-surface)' }}
+    >
+      <button
+        className="w-full flex items-center gap-3 px-4 py-3 text-left"
+        style={{ background: expanded ? '#E031310d' : 'transparent' }}
+        onClick={() => setExpanded((e) => !e)}
+      >
+        <span className="text-base w-6 text-center">📋</span>
+        <span className="text-sm font-semibold flex-1" style={{ color: 'var(--color-text)' }}>MoveOn</span>
+        <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full" style={{ background: 'rgba(107,114,128,0.15)', color: '#6b7280' }}>
+          Manual only
+        </span>
+        {expanded ? <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} />}
+      </button>
+
+      {expanded && (
+        <div className="px-4 pb-4 space-y-3" style={{ borderTop: '1px solid #E0313122' }}>
+          <div className="mt-3 text-xs rounded-lg p-3 space-y-2" style={{ background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.18)', color: '#f87171' }}>
+            <p className="font-semibold">MoveOn does not have a public petition creation API.</p>
+            <p style={{ color: 'var(--color-text-muted)' }}>
+              The MoveOn agent prepares a pre-filled petition URL with your title and target organization. You must open the link and complete submission manually, or Playwright browser automation can be added to automate the form.
+            </p>
+          </div>
+
+          <div>
+            <p className="text-xs font-semibold mb-2" style={{ color: 'var(--color-text-secondary)' }}>How the MoveOn workflow works:</p>
+            <ol className="space-y-2">
+              {[
+                { s: 'Generate petition', d: 'The agent generates your petition title, body, and target organization.' },
+                { s: 'Get the pre-filled URL', d: 'After dispatch, the agent returns a MoveOn URL like: sign.moveon.org/create/?title=...&target=...' },
+                { s: 'Open the URL', d: 'Click the link — it opens MoveOn with the title and target pre-filled.' },
+                { s: 'Complete the form', d: 'Paste in the petition body, select category, add your name/email, then submit.' },
+                { s: 'Share the petition link', d: 'After creation, copy the petition URL and use it in Twitter/Reddit/Discord posts.' },
+              ].map((item, i) => (
+                <li key={i} className="flex gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+                  <span className="flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5" style={{ background: 'rgba(224,49,49,0.15)', color: '#f87171' }}>
+                    {i + 1}
+                  </span>
+                  <div>
+                    <span className="font-semibold" style={{ color: 'var(--color-text)' }}>{item.s}</span>
+                    <span className="ml-1" style={{ color: 'var(--color-text-muted)' }}>— {item.d}</span>
+                  </div>
+                </li>
+              ))}
+            </ol>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+/* ─── Main export ─────────────────────────────────────────────────────────── */
+export default function SocialCredentialsSection() {
+  const [maskedValues, setMaskedValues] = useState<Record<string, Record<string, string>>>({});
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    fetchCreds();
+  }, []);
+
+  async function fetchCreds() {
+    try {
+      const res = await fetch('/api/settings/credentials');
+      if (!res.ok) throw new Error('Failed');
+      const data = await res.json();
+      setMaskedValues(data.credentials || {});
+    } catch {
+      // silent — show empty fields
+    } finally {
+      setLoading(false);
+    }
+  }
+
+  if (loading) {
+    return (
+      <div className="space-y-2">
+        {[1, 2, 3, 4].map((i) => (
+          <div key={i} className="skeleton h-12 rounded-lg" />
+        ))}
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-2">
+      {PLATFORMS.map((p) => (
+        <PlatformCard
+          key={p.id}
+          platform={p}
+          initialValues={maskedValues[p.id] || {}}
+          onSaved={fetchCreds}
+        />
+      ))}
+      <MoveOnCard />
+    </div>
+  );
+}
diff --git a/components/shared/SortDropdown.tsx b/components/shared/SortDropdown.tsx
new file mode 100644
index 0000000..3d5cfe4
--- /dev/null
+++ b/components/shared/SortDropdown.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { ArrowUpDown } from 'lucide-react';
+
+interface SortOption {
+  value: string;
+  label: string;
+}
+
+interface SortDropdownProps {
+  options: SortOption[];
+  value: string;
+  onChange: (value: string) => void;
+}
+
+export default function SortDropdown({ options, value, onChange }: SortDropdownProps) {
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+      <ArrowUpDown size={14} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+      <select
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        style={{
+          backgroundColor: 'var(--color-surface-el)',
+          color: 'var(--color-text)',
+          border: '1px solid var(--color-border)',
+          borderRadius: 8,
+          padding: '6px 10px',
+          fontSize: '0.75rem',
+          fontWeight: 500,
+          cursor: 'pointer',
+          outline: 'none',
+        }}
+      >
+        {options.map((opt) => (
+          <option key={opt.value} value={opt.value}>{opt.label}</option>
+        ))}
+      </select>
+    </div>
+  );
+}
diff --git a/components/sources/SourcesTab.tsx b/components/sources/SourcesTab.tsx
new file mode 100644
index 0000000..698ecea
--- /dev/null
+++ b/components/sources/SourcesTab.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Loader2, Radio, ExternalLink, Edit2, Check, X, Trash2, Plus, AlertCircle } from 'lucide-react';
+
+const S = {
+  bg:'#0E0E10', surface:'#141419', surfaceEl:'#1C1C24', border:'#2A2A35',
+  cyan:'#00F0FF', cyanDim:'rgba(0,240,255,0.12)', green:'#00C389',
+  red:'#FF5C5C', orange:'#F7931A', yellow:'#FBBF24', blue:'#3B82F6',
+  text:'#E0E0E0', textDim:'#A8A8A8', textMuted:'#6B7280',
+  fontHead:"'Space Grotesk', system-ui, sans-serif",
+  fontMono:"'JetBrains Mono', monospace",
+  rSm:'6px', rMd:'10px',
+};
+
+const STATUS_COLOR: Record<string,string> = {
+  healthy:'#00C389', stale:'#FBBF24', error:'#F7931A', failing:'#FF5C5C',
+  dead:'#6B7280', never_fetched:'#374151',
+};
+
+const CAT_COLORS: Record<string,string> = {
+  advocacy:'#A855F7', blog:'#3B82F6', economy:'#F7931A', government:'#64748B',
+  local_tv:'#EC4899', national_tv:'#EF4444', newspaper:'#14B8A6',
+  tech:'#00F0FF', health:'#00C389', social:'#8B5CF6', wire:'#F59E0B',
+  substack:'#06B6D4', magazine:'#F97316',
+};
+
+const ALL_CATS = ['advocacy','blog','economy','government','health','local_tv','magazine','national_tv','newspaper','social','substack','tech','wire'];
+
+function catColor(cat:string){return CAT_COLORS[cat]||S.textMuted;}
+
+interface Feed {
+  id:string; name:string; url:string; category:string; is_active:boolean;
+  health_status:string; article_count:number; error_count:number;
+  last_error:string|null; last_fetched_at:string|null; avg_articles_per_day:number|null;
+}
+interface EditState{id:string;name:string;url:string;category:string;}
+
+export default function SourcesTab() {
+  const [feeds,setFeeds]=useState<Feed[]>([]);
+  const [loading,setLoading]=useState(true);
+  const [filterCat,setFilterCat]=useState('all');
+  const [filterStatus,setFilterStatus]=useState('all');
+  const [search,setSearch]=useState('');
+  const [editing,setEditing]=useState<EditState|null>(null);
+  const [saving,setSaving]=useState<string|null>(null);
+  const [addMode,setAddMode]=useState(false);
+  const [newFeed,setNewFeed]=useState({name:'',url:'',category:'blog'});
+  const [err,setErr]=useState<string|null>(null);
+  const [collapsed,setCollapsed]=useState<Record<string,boolean>>({});
+
+  async function load(){
+    setLoading(true);
+    try{const r=await fetch('/api/pulse/feeds');if(r.ok)setFeeds(await r.json());}catch{}
+    setLoading(false);
+  }
+  useEffect(()=>{load();},[]);
+
+  const categories=[...new Set(feeds.map(f=>f.category))].sort();
+  const filtered=feeds.filter(f=>{
+    if(filterCat!=='all'&&f.category!==filterCat)return false;
+    const st=f.health_status||'never_fetched';
+    if(filterStatus!=='all'&&st!==filterStatus)return false;
+    if(search){const q=search.toLowerCase();if(!f.name.toLowerCase().includes(q)&&!f.url.toLowerCase().includes(q))return false;}
+    return true;
+  });
+  const grouped:Record<string,Feed[]>={};
+  for(const f of filtered){if(!grouped[f.category])grouped[f.category]=[];grouped[f.category].push(f);}
+
+  async function saveEdit(){
+    if(!editing)return;
+    setSaving(editing.id);
+    try{
+      const r=await fetch('/api/pulse/feeds/'+editing.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:editing.name,url:editing.url,category:editing.category})});
+      if(!r.ok)throw new Error((await r.json()).error);
+      await load();setEditing(null);
+    }catch(e:any){setErr(e.message);}
+    setSaving(null);
+  }
+  async function toggleActive(f:Feed){
+    setSaving(f.id);
+    try{await fetch('/api/pulse/feeds/'+f.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active:!f.is_active})});await load();}catch{}
+    setSaving(null);
+  }
+  async function deleteFeed(id:string){
+    if(!confirm('Delete this feed?'))return;
+    try{await fetch('/api/pulse/feeds/'+id,{method:'DELETE'});await load();}catch{}
+  }
+  async function addFeed(){
+    if(!newFeed.name||!newFeed.url)return;
+    try{
+      const r=await fetch('/api/pulse/feeds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(newFeed)});
+      if(!r.ok)throw new Error((await r.json()).error);
+      setNewFeed({name:'',url:'',category:'blog'});setAddMode(false);await load();
+    }catch(e:any){setErr(e.message);}
+  }
+
+  const totalActive=feeds.filter(f=>f.is_active).length;
+  const totalHealthy=feeds.filter(f=>f.health_status==='healthy').length;
+  const totalError=feeds.filter(f=>f.health_status==='error'||f.health_status==='failing').length;
+
+  if(loading)return(
+    <div style={{padding:40,display:'flex',alignItems:'center',gap:10,color:S.textMuted,fontFamily:S.fontMono}}>
+      <Loader2 size={16} style={{animation:'spin 1s linear infinite'}}/>Loading sources…
+    </div>
+  );
+
+  return(
+    <div style={{fontFamily:S.fontHead,color:S.text,paddingBottom:40}}>
+      {/* Header */}
+      <div style={{padding:'20px 24px 16px',display:'flex',alignItems:'flex-start',gap:16,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
+        <div style={{flex:1}}>
+          <div style={{display:'flex',alignItems:'center',gap:10}}>
+            <Radio size={18} color={S.cyan}/>
+            <span style={{fontSize:18,fontWeight:700}}>Sources</span>
+            <span style={{fontFamily:S.fontMono,fontSize:11,color:S.cyan,background:S.cyanDim,padding:'2px 8px',borderRadius:4,border:`1px solid ${S.cyan}44`}}>{feeds.length} feeds</span>
+          </div>
+          <div style={{display:'flex',gap:20,marginTop:6,fontSize:12,color:S.textMuted}}>
+            <span>Active: <strong style={{color:S.green}}>{totalActive}</strong></span>
+            <span>Healthy: <strong style={{color:S.green}}>{totalHealthy}</strong></span>
+            <span>Error: <strong style={{color:S.red}}>{totalError}</strong></span>
+            <span>Inactive: <strong style={{color:S.textMuted}}>{feeds.filter(f=>!f.is_active).length}</strong></span>
+          </div>
+        </div>
+        <button onClick={()=>{setAddMode(!addMode);setErr(null);}}
+          style={{display:'flex',alignItems:'center',gap:6,padding:'7px 14px',background:addMode?S.cyanDim:'transparent',border:`1px solid ${addMode?S.cyan:S.border}`,borderRadius:S.rSm,color:addMode?S.cyan:S.textDim,cursor:'pointer',fontSize:12,fontWeight:600}}>
+          <Plus size={14}/> Add Feed
+        </button>
+      </div>
+
+      {/* Error */}
+      {err&&(
+        <div style={{margin:'12px 24px',padding:'8px 12px',background:'rgba(255,92,92,0.1)',border:`1px solid ${S.red}44`,borderRadius:S.rSm,fontSize:12,color:S.red,display:'flex',gap:8,alignItems:'center'}}>
+          <AlertCircle size={13}/>{err}
+          <button onClick={()=>setErr(null)} style={{marginLeft:'auto',background:'none',border:'none',color:S.red,cursor:'pointer'}}><X size={13}/></button>
+        </div>
+      )}
+
+      {/* Add Form */}
+      {addMode&&(
+        <div style={{margin:'12px 24px',padding:16,background:S.surfaceEl,border:`1px solid ${S.cyan}44`,borderRadius:S.rMd}}>
+          <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:10,alignItems:'end'}}>
+            <div>
+              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Name</div>
+              <input value={newFeed.name} onChange={e=>setNewFeed(p=>({...p,name:e.target.value}))} placeholder="Source name"
+                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}/>
+            </div>
+            <div>
+              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>RSS URL</div>
+              <input value={newFeed.url} onChange={e=>setNewFeed(p=>({...p,url:e.target.value}))} placeholder="https://example.com/feed.rss"
+                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none',boxSizing:'border-box' as any}}/>
+            </div>
+            <div>
+              <div style={{fontSize:10,color:S.textMuted,marginBottom:4,textTransform:'uppercase',letterSpacing:'0.5px'}}>Category</div>
+              <select value={newFeed.category} onChange={e=>setNewFeed(p=>({...p,category:e.target.value}))}
+                style={{width:'100%',padding:'6px 10px',background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none',boxSizing:'border-box' as any}}>
+                {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
+              </select>
+            </div>
+            <div style={{display:'flex',gap:6}}>
+              <button onClick={addFeed} disabled={!newFeed.name||!newFeed.url}
+                style={{padding:'6px 14px',background:S.cyan,color:S.bg,border:'none',borderRadius:S.rSm,fontSize:12,fontWeight:700,cursor:'pointer',opacity:(!newFeed.name||!newFeed.url)?0.4:1}}>Add</button>
+              <button onClick={()=>setAddMode(false)}
+                style={{padding:'6px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',fontSize:12}}>Cancel</button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Status Filters */}
+      <div style={{padding:'10px 24px',display:'flex',gap:8,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`,alignItems:'center'}}>
+        {(['all','healthy','stale','error','failing','dead','never_fetched'] as const).map(s=>{
+          const col=s==='all'?S.cyan:(STATUS_COLOR[s]||S.textMuted);
+          const cnt=s==='all'?feeds.length:feeds.filter(f=>(f.health_status||'never_fetched')===s).length;
+          if(s!=='all'&&cnt===0)return null;
+          return(
+            <button key={s} onClick={()=>setFilterStatus(s)}
+              style={{padding:'3px 10px',borderRadius:20,fontSize:11,cursor:'pointer',fontFamily:S.fontMono,border:filterStatus===s?`1px solid ${col}`:`1px solid ${S.border}`,background:filterStatus===s?col+'22':'transparent',color:filterStatus===s?col:S.textMuted}}>
+              {s.replace(/_/g,' ')} ({cnt})
+            </button>
+          );
+        })}
+        <input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search name or URL…"
+          style={{marginLeft:'auto',padding:'4px 10px',background:S.surfaceEl,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:12,fontFamily:S.fontMono,outline:'none',width:220}}/>
+      </div>
+
+      {/* Category Pills */}
+      <div style={{padding:'10px 24px',display:'flex',gap:6,flexWrap:'wrap',borderBottom:`1px solid ${S.border}`}}>
+        <button onClick={()=>setFilterCat('all')}
+          style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat==='all'?`1px solid ${S.cyan}`:`1px solid ${S.border}`,background:filterCat==='all'?S.cyanDim:'transparent',color:filterCat==='all'?S.cyan:S.textMuted}}>
+          All
+        </button>
+        {categories.map(cat=>{
+          const col=catColor(cat);
+          const cnt=feeds.filter(f=>f.category===cat).length;
+          return(
+            <button key={cat} onClick={()=>setFilterCat(filterCat===cat?'all':cat)}
+              style={{padding:'3px 10px',borderRadius:12,fontSize:11,cursor:'pointer',border:filterCat===cat?`1px solid ${col}`:`1px solid ${S.border}`,background:filterCat===cat?col+'22':'transparent',color:filterCat===cat?col:S.textMuted}}>
+              {cat} ({cnt})
+            </button>
+          );
+        })}
+      </div>
+
+      {/* Feed Groups */}
+      <div style={{padding:'16px 24px 0'}}>
+        {Object.keys(grouped).sort().map(cat=>{
+          const isCol=!!collapsed[cat];
+          const col=catColor(cat);
+          const catFeeds=grouped[cat];
+          return(
+            <div key={cat} style={{marginBottom:12,background:S.surface,border:`1px solid ${S.border}`,borderRadius:S.rMd,overflow:'hidden'}}>
+              <div onClick={()=>setCollapsed(p=>({...p,[cat]:!p[cat]}))}
+                style={{padding:'10px 16px',display:'flex',alignItems:'center',gap:10,cursor:'pointer',userSelect:'none',borderBottom:isCol?'none':`1px solid ${S.border}`}}>
+                <div style={{width:8,height:8,borderRadius:'50%',background:col,flexShrink:0}}/>
+                <span style={{fontSize:13,fontWeight:700,flex:1,textTransform:'capitalize'}}>{cat}</span>
+                <span style={{fontFamily:S.fontMono,fontSize:11,color:S.textMuted}}>{catFeeds.length} feeds</span>
+                <span style={{fontSize:10,color:S.textMuted,display:'inline-block',transform:isCol?'rotate(-90deg)':'rotate(0deg)',transition:'transform 0.2s'}}>▼</span>
+              </div>
+              {!isCol&&(
+                <div>
+                  {catFeeds.map((f,idx)=>{
+                    const isEdit=editing?.id===f.id;
+                    const isSave=saving===f.id;
+                    const sCol=STATUS_COLOR[f.health_status||'never_fetched']||S.textMuted;
+                    return(
+                      <div key={f.id} style={{padding:'10px 16px',background:isEdit?S.surfaceEl:'transparent',borderBottom:idx<catFeeds.length-1?`1px solid ${S.border}22`:'none'}}>
+                        {isEdit?(
+                          <div style={{display:'grid',gridTemplateColumns:'180px 1fr 140px auto',gap:8,alignItems:'center'}}>
+                            <input value={editing.name} onChange={e=>setEditing(p=>p&&({...p,name:e.target.value}))}
+                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.text,fontSize:12,outline:'none'}}/>
+                            <input value={editing.url} onChange={e=>setEditing(p=>p&&({...p,url:e.target.value}))}
+                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.cyan}66`,borderRadius:S.rSm,color:S.cyan,fontSize:11,fontFamily:S.fontMono,outline:'none'}}/>
+                            <select value={editing.category} onChange={e=>setEditing(p=>p&&({...p,category:e.target.value}))}
+                              style={{padding:'5px 8px',background:S.bg,border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.text,fontSize:11,outline:'none'}}>
+                              {ALL_CATS.map(c=><option key={c} value={c}>{c}</option>)}
+                            </select>
+                            <div style={{display:'flex',gap:6}}>
+                              <button onClick={saveEdit} disabled={isSave}
+                                style={{padding:'5px 10px',background:S.green+'22',border:`1px solid ${S.green}`,borderRadius:S.rSm,color:S.green,cursor:'pointer',display:'flex',alignItems:'center'}}>
+                                {isSave?<Loader2 size={12} style={{animation:'spin 1s linear infinite'}}/>:<Check size={12}/>}
+                              </button>
+                              <button onClick={()=>setEditing(null)}
+                                style={{padding:'5px 10px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+                                <X size={12}/>
+                              </button>
+                            </div>
+                          </div>
+                        ):(
+                          <div style={{display:'grid',gridTemplateColumns:'20px 1fr 2fr auto',gap:12,alignItems:'center'}}>
+                            <div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:4}}>
+                              <div style={{width:7,height:7,borderRadius:'50%',background:sCol}} title={f.health_status||'never_fetched'}/>
+                              <div onClick={()=>toggleActive(f)} title={f.is_active?'Active — click to disable':'Inactive — click to enable'}
+                                style={{width:20,height:11,borderRadius:6,background:f.is_active?S.green+'44':S.border,cursor:'pointer',position:'relative',transition:'background 0.2s'}}>
+                                <div style={{position:'absolute',top:2,right:f.is_active?2:undefined,left:f.is_active?undefined:2,width:7,height:7,borderRadius:'50%',background:f.is_active?S.green:S.textMuted,transition:'all 0.2s'}}/>
+                              </div>
+                            </div>
+                            <div style={{overflow:'hidden',minWidth:0}}>
+                              <div style={{fontSize:13,fontWeight:600,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.name}</div>
+                              <div style={{display:'flex',gap:12,marginTop:2,fontSize:10,color:S.textMuted,fontFamily:S.fontMono}}>
+                                <span>{(f.article_count??0).toLocaleString()} arts</span>
+                                {(f.error_count??0)>0&&<span style={{color:S.red}}>{f.error_count} err</span>}
+                                {f.avg_articles_per_day!=null&&<span>{f.avg_articles_per_day.toFixed(1)}/day</span>}
+                              </div>
+                              {f.last_error&&<div style={{fontSize:10,color:S.red,marginTop:2,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}}>{f.last_error}</div>}
+                            </div>
+                            <div style={{overflow:'hidden',minWidth:0}}>
+                              <a href={f.url} target="_blank" rel="noopener noreferrer"
+                                style={{fontSize:11,color:S.cyan,fontFamily:S.fontMono,textDecoration:'none',display:'flex',alignItems:'center',gap:4,overflow:'hidden',whiteSpace:'nowrap',textOverflow:'ellipsis'}} title={f.url}>
+                                <ExternalLink size={10} style={{flexShrink:0}}/>{f.url}
+                              </a>
+                              {f.last_fetched_at&&(
+                                <div style={{fontSize:10,color:S.textMuted,marginTop:2}}>
+                                  Last: {new Date(f.last_fetched_at).toLocaleString('en-US',{timeZone:'America/Los_Angeles',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',hour12:true})} PT
+                                </div>
+                              )}
+                            </div>
+                            <div style={{display:'flex',gap:5}}>
+                              <button onClick={()=>{setEditing({id:f.id,name:f.name,url:f.url,category:f.category});setErr(null);}} title="Edit"
+                                style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+                                <Edit2 size={12}/>
+                              </button>
+                              <button onClick={()=>deleteFeed(f.id)} title="Delete"
+                                style={{padding:'5px 8px',background:'transparent',border:`1px solid ${S.border}`,borderRadius:S.rSm,color:S.textMuted,cursor:'pointer',display:'flex',alignItems:'center'}}>
+                                <Trash2 size={12}/>
+                              </button>
+                            </div>
+                          </div>
+                        )}
+                      </div>
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+          );
+        })}
+        {filtered.length===0&&(
+          <div style={{padding:40,textAlign:'center',color:S.textMuted,fontFamily:S.fontMono,fontSize:13}}>No feeds match the current filters.</div>
+        )}
+      </div>
+    </div>
+  );
+}
diff --git a/components/subscribers/SubscribersTab.tsx b/components/subscribers/SubscribersTab.tsx
new file mode 100644
index 0000000..d4ec265
--- /dev/null
+++ b/components/subscribers/SubscribersTab.tsx
@@ -0,0 +1,446 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+  Users, UserPlus, Search, Loader2, X, Copy,
+  Tag, ToggleLeft, ToggleRight, UserMinus,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import { useDebounce } from '@/hooks/useDebounce';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const SUB_SORT_OPTIONS = [
+  { value: 'email_asc', label: 'Email A-Z' },
+  { value: 'date_desc', label: 'Newest First' },
+  { value: 'date_asc', label: 'Oldest First' },
+  { value: 'active_desc', label: 'Active First' },
+];
+
+const SUB_SORT_CONFIGS: Record<string, SortConfig> = {
+  email_asc: { key: 'email', direction: 'asc', type: 'string' },
+  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+  active_desc: { key: 'is_active', direction: 'desc', type: 'string' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Subscriber {
+  id: string;
+  email: string;
+  name: string | null;
+  zip_code: string | null;
+  source: string;
+  petition_ids: string[] | null;
+  tags: string[] | null;
+  is_active: boolean;
+  unsubscribed_at: string | null;
+  created_at: string;
+}
+
+interface Stats {
+  total: number;
+  active: number;
+  inactive: number;
+  by_source: Record<string, number>;
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function SubscribersTab() {
+  const { addToast } = useToast();
+  const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [stats, setStats] = useState<Stats>({ total: 0, active: 0, inactive: 0, by_source: {} });
+  const [search, setSearch] = useState('');
+  const debouncedSearch = useDebounce(search, 300);
+  const [sourceFilter, setSourceFilter] = useState('all');
+  const [sortBy, setSortBy] = useState('date_desc');
+  const sortedSubscribers = useClientSort(subscribers, sortBy, SUB_SORT_CONFIGS);
+
+  // Add modal
+  const [showAdd, setShowAdd] = useState(false);
+  const [addEmail, setAddEmail] = useState('');
+  const [addName, setAddName] = useState('');
+  const [addZip, setAddZip] = useState('');
+  const [addSaving, setAddSaving] = useState(false);
+
+  // Selected for bulk
+  const [selected, setSelected] = useState<Set<string>>(new Set());
+
+  /* ── Fetch ──────────────────────────────────────────────────── */
+  const fetchSubscribers = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      if (debouncedSearch) params.set('search', debouncedSearch);
+      if (sourceFilter !== 'all') params.set('source', sourceFilter);
+      const res = await fetch(`/api/subscribers?${params.toString()}`);
+      if (res.ok) {
+        const data = await res.json();
+        setSubscribers(data.rows || []);
+        setStats(data.stats || { total: 0, active: 0, inactive: 0, by_source: {} });
+      }
+    } catch (err) {
+      console.error('Fetch subscribers error:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [debouncedSearch, sourceFilter]);
+
+  useEffect(() => { fetchSubscribers(); }, [fetchSubscribers]);
+
+  /* ── Escape key handler ───────────────────────────────────────── */
+  useEffect(() => {
+    if (!showAdd) return;
+    const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowAdd(false); };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [showAdd]);
+
+  /* ── Add Subscriber ─────────────────────────────────────────── */
+  async function handleAdd() {
+    if (!addEmail.trim()) return;
+    setAddSaving(true);
+    try {
+      const res = await fetch('/api/subscribers', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ email: addEmail, name: addName || null, zip_code: addZip || null, source: 'manual' }),
+      });
+      if (res.ok) {
+        setShowAdd(false);
+        setAddEmail('');
+        setAddName('');
+        setAddZip('');
+        fetchSubscribers();
+        addToast('Subscriber added', 'success');
+      } else {
+        addToast('Failed to add subscriber', 'error');
+      }
+    } catch (err) {
+      console.error('Add subscriber error:', err);
+      addToast('Failed to add subscriber', 'error');
+    } finally {
+      setAddSaving(false);
+    }
+  }
+
+  /* ── Toggle Active ──────────────────────────────────────────── */
+  async function toggleActive(sub: Subscriber) {
+    try {
+      if (sub.is_active) {
+        // Unsubscribe
+        await fetch(`/api/subscribers?id=${sub.id}`, { method: 'DELETE' });
+      } else {
+        // Reactivate
+        await fetch('/api/subscribers', {
+          method: 'PATCH',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ id: sub.id, is_active: true }),
+        });
+      }
+      fetchSubscribers();
+      addToast(sub.is_active ? 'Subscriber deactivated' : 'Subscriber reactivated', 'info');
+    } catch (err) {
+      console.error('Toggle active error:', err);
+      addToast('Toggle failed', 'error');
+    }
+  }
+
+  /* ── Unsubscribe ────────────────────────────────────────────── */
+  async function handleUnsubscribe(id: string) {
+    if (!confirm('Unsubscribe this subscriber?')) return;
+    try {
+      await fetch(`/api/subscribers?id=${id}`, { method: 'DELETE' });
+      fetchSubscribers();
+      addToast('Subscriber unsubscribed', 'info');
+    } catch (err) {
+      console.error('Unsubscribe error:', err);
+      addToast('Unsubscribe failed', 'error');
+    }
+  }
+
+  /* ── Bulk Copy Emails ───────────────────────────────────────── */
+  function handleCopyEmails() {
+    const emails = subscribers
+      .filter((s) => selected.has(s.id))
+      .map((s) => s.email)
+      .join(', ');
+    navigator.clipboard.writeText(emails);
+    addToast(`${selected.size} email(s) copied`, 'success');
+  }
+
+  /* ── Selection ──────────────────────────────────────────────── */
+  function toggleSelected(id: string) {
+    const next = new Set(selected);
+    if (next.has(id)) next.delete(id);
+    else next.add(id);
+    setSelected(next);
+  }
+
+  function toggleAll() {
+    if (selected.size === subscribers.length) {
+      setSelected(new Set());
+    } else {
+      setSelected(new Set(subscribers.map((s) => s.id)));
+    }
+  }
+
+  /* ── Helpers ────────────────────────────────────────────────── */
+  const sourceLabels: string[] = ['all', ...Object.keys(stats.by_source)];
+
+  const sourceBadgeColor: Record<string, string> = {
+    manual: '#a78bfa',
+    petition: '#7c3aed',
+    signup: '#22c55e',
+    import: '#3b82f6',
+  };
+
+  const formatDate = (d: string) => {
+    return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+  };
+
+  /* ─── Render ────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Subscribers</h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            Manage your email list across all petitions
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {selected.size > 0 && (
+            <button className="btn btn-secondary btn-sm" onClick={handleCopyEmails} title="Copy selected emails">
+              <Copy size={14} /> Copy {selected.size} Emails
+            </button>
+          )}
+          <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
+            <UserPlus size={16} />
+            Add Subscriber
+          </button>
+        </div>
+      </div>
+
+      {/* Stats Row */}
+      <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
+        <div className="card">
+          <div className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>{stats.total}</div>
+          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Total Subscribers</div>
+        </div>
+        <div className="card">
+          <div className="text-2xl font-bold" style={{ color: 'var(--color-success)' }}>{stats.active}</div>
+          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Active</div>
+        </div>
+        <div className="card">
+          <div className="text-2xl font-bold" style={{ color: 'var(--color-error)' }}>{stats.inactive}</div>
+          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Inactive</div>
+        </div>
+        <div className="card">
+          <div className="flex items-center gap-2 flex-wrap">
+            {Object.entries(stats.by_source).map(([src, cnt]) => (
+              <span key={src} className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
+                {src}: <strong>{cnt}</strong>
+              </span>
+            ))}
+            {Object.keys(stats.by_source).length === 0 && (
+              <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>No sources</span>
+            )}
+          </div>
+          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>By Source</div>
+        </div>
+      </div>
+
+      {/* Sort + Search + Filters */}
+      <div className="flex items-center gap-3 mb-6 flex-wrap">
+        <SortDropdown options={SUB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+        <div className="flex-1 relative" style={{ minWidth: 200 }}>
+          <Search
+            size={16}
+            style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
+          />
+          <input
+            className="input"
+            placeholder="Search by email or name..."
+            style={{ paddingLeft: 36 }}
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+          />
+        </div>
+        <div className="flex items-center gap-1 flex-wrap">
+          {sourceLabels.map((s) => (
+            <button
+              key={s}
+              onClick={() => setSourceFilter(s)}
+              className={`btn btn-sm ${sourceFilter === s ? 'btn-primary' : 'btn-ghost'}`}
+              style={{ textTransform: 'capitalize' }}
+            >
+              {s === 'all' ? 'All Sources' : s}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Loading */}
+      {loading && (
+        <SkeletonList count={4} />
+      )}
+
+      {/* Empty State */}
+      {!loading && subscribers.length === 0 && (
+        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+          <div
+            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+            style={{
+              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+              border: '1px solid rgba(124, 58, 237, 0.2)',
+            }}
+          >
+            <Users size={28} style={{ color: 'var(--color-primary)' }} />
+          </div>
+          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No subscribers yet</h3>
+          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+            Subscribers are added when people sign petitions or you add them manually.
+          </p>
+          <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
+            <UserPlus size={16} /> Add Manually
+          </button>
+        </div>
+      )}
+
+      {/* Subscriber Table */}
+      {!loading && subscribers.length > 0 && (
+        <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
+          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+            <thead>
+              <tr style={{ borderBottom: '1px solid var(--color-border)' }}>
+                <th style={{ padding: '10px 12px', textAlign: 'left' }}>
+                  <input
+                    type="checkbox"
+                    checked={selected.size === subscribers.length && subscribers.length > 0}
+                    onChange={toggleAll}
+                    style={{ accentColor: 'var(--color-primary)' }}
+                  />
+                </th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Email</th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Name</th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Zip</th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Source</th>
+                <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Active</th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Petitions</th>
+                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Joined</th>
+                <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Actions</th>
+              </tr>
+            </thead>
+            <tbody>
+              {sortedSubscribers.map((s) => {
+                const srcColor = sourceBadgeColor[s.source] || 'var(--color-text-muted)';
+                return (
+                  <tr
+                    key={s.id}
+                    style={{
+                      borderBottom: '1px solid var(--color-border)',
+                      backgroundColor: selected.has(s.id) ? 'rgba(124, 58, 237, 0.05)' : 'transparent',
+                    }}
+                  >
+                    <td style={{ padding: '8px 12px' }}>
+                      <input
+                        type="checkbox"
+                        checked={selected.has(s.id)}
+                        onChange={() => toggleSelected(s.id)}
+                        style={{ accentColor: 'var(--color-primary)' }}
+                      />
+                    </td>
+                    <td style={{ padding: '8px 12px', color: 'var(--color-text)' }}>{s.email}</td>
+                    <td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{s.name || '--'}</td>
+                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>{s.zip_code || '--'}</td>
+                    <td style={{ padding: '8px 12px' }}>
+                      <span
+                        className="badge"
+                        style={{
+                          backgroundColor: `${srcColor}20`,
+                          color: srcColor,
+                          border: `1px solid ${srcColor}40`,
+                        }}
+                      >
+                        {s.source}
+                      </span>
+                    </td>
+                    <td style={{ padding: '8px 12px', textAlign: 'center' }}>
+                      <button
+                        className="btn btn-ghost btn-sm"
+                        onClick={() => toggleActive(s)}
+                        style={{ color: s.is_active ? 'var(--color-success)' : 'var(--color-error)' }}
+                        title={s.is_active ? 'Active - click to deactivate' : 'Inactive - click to reactivate'}
+                      >
+                        {s.is_active ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
+                      </button>
+                    </td>
+                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>
+                      {s.petition_ids?.length || 0}
+                    </td>
+                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', fontSize: 12 }}>
+                      {formatDate(s.created_at)}
+                    </td>
+                    <td style={{ padding: '8px 12px', textAlign: 'center' }}>
+                      <button
+                        className="btn btn-ghost btn-sm"
+                        onClick={() => handleUnsubscribe(s.id)}
+                        style={{ color: 'var(--color-error)' }}
+                        title="Unsubscribe"
+                      >
+                        <UserMinus size={14} />
+                      </button>
+                    </td>
+                  </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {/* ─── Add Subscriber Modal ─────────────────────────────────── */}
+      {showAdd && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setShowAdd(false); }}
+        >
+          <div style={{
+            width: '100%', maxWidth: 420, backgroundColor: 'var(--color-surface)',
+            border: '1px solid var(--color-border)', borderRadius: 12, padding: 24,
+          }}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Add Subscriber</h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(false)}><X size={16} /></button>
+            </div>
+            <div className="flex flex-col gap-4">
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Email *</label>
+                <input className="input" type="email" value={addEmail} onChange={(e) => setAddEmail(e.target.value)} placeholder="email@example.com" />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name</label>
+                <input className="input" value={addName} onChange={(e) => setAddName(e.target.value)} placeholder="Full name" />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Zip Code</label>
+                <input className="input" value={addZip} onChange={(e) => setAddZip(e.target.value)} placeholder="12345" />
+              </div>
+              <button className="btn btn-primary w-full" onClick={handleAdd} disabled={addSaving || !addEmail.trim()}>
+                {addSaving ? <><Loader2 size={16} className="animate-spin" /> Adding...</> : <><UserPlus size={16} /> Add Subscriber</>}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/templates/TemplatesTab.tsx b/components/templates/TemplatesTab.tsx
new file mode 100644
index 0000000..cb28d4a
--- /dev/null
+++ b/components/templates/TemplatesTab.tsx
@@ -0,0 +1,395 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+  FileText, Plus, Star, Loader2, X, Sparkles,
+  Tag, Copy, Megaphone,
+} from 'lucide-react';
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface Template {
+  id: string;
+  title: string;
+  category: string | null;
+  body_html: string;
+  body_text: string | null;
+  tags: string[] | null;
+  usage_count: number;
+  is_featured: boolean;
+  created_at: string;
+}
+
+function stripHtml(html: string): string {
+  return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function TemplatesTab() {
+  const [templates, setTemplates] = useState<Template[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [categoryFilter, setCategoryFilter] = useState('all');
+
+  // Create template modal
+  const [showCreate, setShowCreate] = useState(false);
+  const [createTitle, setCreateTitle] = useState('');
+  const [createCategory, setCreateCategory] = useState('policy');
+  const [createBody, setCreateBody] = useState('');
+  const [createTags, setCreateTags] = useState('');
+  const [createFeatured, setCreateFeatured] = useState(false);
+  const [createSaving, setCreateSaving] = useState(false);
+
+  // Use template modal (create petition from template)
+  const [useTemplate, setUseTemplate] = useState<Template | null>(null);
+  const [petTitle, setPetTitle] = useState('');
+  const [petTarget, setPetTarget] = useState('');
+  const [petGoal, setPetGoal] = useState(100);
+  const [petSaving, setPetSaving] = useState(false);
+
+  /* ── Fetch ──────────────────────────────────────────────────── */
+  const fetchTemplates = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      if (categoryFilter !== 'all') params.set('category', categoryFilter);
+      const res = await fetch(`/api/templates?${params.toString()}`);
+      if (res.ok) {
+        const data = await res.json();
+        setTemplates(data.rows || []);
+      }
+    } catch (err) {
+      console.error('Fetch templates error:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [categoryFilter]);
+
+  useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
+
+  /* ── Create Template ────────────────────────────────────────── */
+  async function handleCreate() {
+    if (!createTitle.trim() || !createBody.trim()) return;
+    setCreateSaving(true);
+    try {
+      const tags = createTags.split(',').map((t) => t.trim()).filter(Boolean);
+      const res = await fetch('/api/templates', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: createTitle,
+          category: createCategory,
+          body_html: `<p>${createBody}</p>`,
+          body_text: createBody,
+          tags: tags.length > 0 ? tags : null,
+          is_featured: createFeatured,
+        }),
+      });
+      if (res.ok) {
+        setShowCreate(false);
+        setCreateTitle('');
+        setCreateBody('');
+        setCreateTags('');
+        setCreateFeatured(false);
+        fetchTemplates();
+      }
+    } catch (err) {
+      console.error('Create template error:', err);
+    } finally {
+      setCreateSaving(false);
+    }
+  }
+
+  /* ── Use Template ───────────────────────────────────────────── */
+  function openUseTemplate(t: Template) {
+    setUseTemplate(t);
+    setPetTitle(t.title);
+    setPetTarget('');
+    setPetGoal(100);
+  }
+
+  async function handleUseSave() {
+    if (!useTemplate || !petTitle.trim()) return;
+    setPetSaving(true);
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: petTitle,
+          body_html: useTemplate.body_html,
+          body_text: useTemplate.body_text,
+          target: petTarget || null,
+          category: useTemplate.category,
+          tags: useTemplate.tags,
+          signature_goal: petGoal,
+          status: 'draft',
+        }),
+      });
+      if (res.ok) {
+        setUseTemplate(null);
+      }
+    } catch (err) {
+      console.error('Use template error:', err);
+    } finally {
+      setPetSaving(false);
+    }
+  }
+
+  /* ── Helpers ────────────────────────────────────────────────── */
+  const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection'];
+
+  const categoryLabels: Record<string, string> = {
+    all: 'All',
+    debt_relief: 'Debt Relief',
+    policy: 'Policy',
+    education_access: 'Education Access',
+    consumer_protection: 'Consumer Protection',
+  };
+
+  /* ─── Render ────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Petition Templates</h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            Pre-built petition templates for common advocacy scenarios
+          </p>
+        </div>
+        <button className="btn btn-primary" onClick={() => setShowCreate(true)}>
+          <Plus size={16} />
+          New Template
+        </button>
+      </div>
+
+      {/* Category Filters */}
+      <div className="flex items-center gap-2 mb-6 flex-wrap">
+        {categories.map((c) => (
+          <button
+            key={c}
+            onClick={() => setCategoryFilter(c)}
+            className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
+          >
+            {categoryLabels[c] || c}
+          </button>
+        ))}
+      </div>
+
+      {/* Loading */}
+      {loading && (
+        <SkeletonList count={4} />
+      )}
+
+      {/* Empty State */}
+      {!loading && templates.length === 0 && (
+        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+          <div
+            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+            style={{
+              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+              border: '1px solid rgba(124, 58, 237, 0.2)',
+            }}
+          >
+            <FileText size={28} style={{ color: 'var(--color-primary)' }} />
+          </div>
+          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No templates found</h3>
+          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+            Templates help you quickly create petitions. Try a different category filter or create a new template.
+          </p>
+          <button className="btn btn-primary" onClick={() => setShowCreate(true)}>
+            <Plus size={16} /> Create Template
+          </button>
+        </div>
+      )}
+
+      {/* Template Grid */}
+      {!loading && templates.length > 0 && (
+        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
+          {templates.map((t) => (
+            <div key={t.id} className="card flex flex-col">
+              {/* Header */}
+              <div className="flex items-start justify-between gap-2 mb-2">
+                <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+                  {t.title}
+                </h3>
+                {t.is_featured && (
+                  <Star size={14} style={{ color: 'var(--color-warning)', fill: 'var(--color-warning)', flexShrink: 0 }} />
+                )}
+              </div>
+
+              {/* Category + Usage */}
+              <div className="flex items-center gap-2 mb-3">
+                {t.category && (
+                  <span
+                    className="badge"
+                    style={{
+                      backgroundColor: 'rgba(124, 58, 237, 0.1)',
+                      color: 'var(--color-secondary)',
+                      border: '1px solid rgba(124, 58, 237, 0.2)',
+                    }}
+                  >
+                    {categoryLabels[t.category] || t.category}
+                  </span>
+                )}
+                <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                  Used {t.usage_count} times
+                </span>
+              </div>
+
+              {/* Body Preview */}
+              <p
+                className="text-xs mb-3 flex-1"
+                style={{
+                  color: 'var(--color-text-muted)',
+                  display: '-webkit-box',
+                  WebkitLineClamp: 3,
+                  WebkitBoxOrient: 'vertical',
+                  overflow: 'hidden',
+                }}
+              >
+                {stripHtml(t.body_html).slice(0, 180)}
+              </p>
+
+              {/* Tags */}
+              {t.tags && t.tags.length > 0 && (
+                <div className="flex items-center gap-1 mb-3 flex-wrap">
+                  {t.tags.map((tag, i) => (
+                    <span key={i} className="text-xs" style={{
+                      color: 'var(--color-secondary)',
+                      padding: '1px 6px',
+                      borderRadius: 9999,
+                      backgroundColor: 'rgba(124, 58, 237, 0.08)',
+                    }}>
+                      {tag}
+                    </span>
+                  ))}
+                </div>
+              )}
+
+              {/* Use Template Button */}
+              <button
+                className="btn btn-sm btn-secondary w-full"
+                onClick={() => openUseTemplate(t)}
+              >
+                <Megaphone size={14} /> Use Template
+              </button>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* ─── Create Template Modal ────────────────────────────────── */}
+      {showCreate && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
+        >
+          <div style={{
+            width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+            borderRadius: 12, padding: 24,
+          }}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Template</h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
+            </div>
+            <div className="flex flex-col gap-4">
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Title *</label>
+                <input className="input" value={createTitle} onChange={(e) => setCreateTitle(e.target.value)} placeholder="Template title" />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+                <select className="input" value={createCategory} onChange={(e) => setCreateCategory(e.target.value)}>
+                  <option value="debt_relief">Debt Relief</option>
+                  <option value="policy">Policy</option>
+                  <option value="education_access">Education Access</option>
+                  <option value="consumer_protection">Consumer Protection</option>
+                </select>
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
+                <textarea
+                  className="input"
+                  rows={6}
+                  value={createBody}
+                  onChange={(e) => setCreateBody(e.target.value)}
+                  placeholder="Petition template body. Use [TARGET] as placeholder for the petition target."
+                  style={{ resize: 'vertical' }}
+                />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tags (comma separated)</label>
+                <input className="input" value={createTags} onChange={(e) => setCreateTags(e.target.value)} placeholder="student loans, policy, reform" />
+              </div>
+              <div className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  checked={createFeatured}
+                  onChange={(e) => setCreateFeatured(e.target.checked)}
+                  style={{ accentColor: 'var(--color-primary)' }}
+                  id="featured-check"
+                />
+                <label htmlFor="featured-check" className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>Featured template</label>
+              </div>
+              <button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createTitle.trim() || !createBody.trim()}>
+                {createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Template</>}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* ─── Use Template Modal ───────────────────────────────────── */}
+      {useTemplate && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setUseTemplate(null); }}
+        >
+          <div style={{
+            width: '100%', maxWidth: 500, maxHeight: '90vh', overflow: 'auto',
+            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
+            borderRadius: 12, padding: 24,
+          }}>
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Petition from Template</h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setUseTemplate(null)}><X size={16} /></button>
+            </div>
+            <div className="flex flex-col gap-4">
+              <div className="text-xs" style={{
+                color: 'var(--color-text-muted)', padding: 8, borderRadius: 6,
+                backgroundColor: 'var(--color-surface-el)', whiteSpace: 'pre-wrap',
+              }}>
+                {stripHtml(useTemplate.body_html).slice(0, 300)}...
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Petition Title *</label>
+                <input className="input" value={petTitle} onChange={(e) => setPetTitle(e.target.value)} />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+                <input className="input" value={petTarget} onChange={(e) => setPetTarget(e.target.value)} placeholder="e.g., Congress" />
+              </div>
+              <div>
+                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Signature Goal</label>
+                <input className="input" type="number" value={petGoal} onChange={(e) => setPetGoal(Number(e.target.value))} />
+              </div>
+              <button className="btn btn-primary w-full" onClick={handleUseSave} disabled={petSaving || !petTitle.trim()}>
+                {petSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Megaphone size={16} /> Create Petition Draft</>}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/topics/TopicsTab.tsx b/components/topics/TopicsTab.tsx
new file mode 100644
index 0000000..723a485
--- /dev/null
+++ b/components/topics/TopicsTab.tsx
@@ -0,0 +1,415 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import {
+  Loader2, Sparkles, ChevronDown, ChevronUp,
+  Target, MapPin, FileText, ExternalLink, AlertTriangle,
+  DollarSign,
+} from 'lucide-react';
+import { Grant, formatAmount, STATUS_COLORS } from '../grants/types';
+
+interface Topic {
+  id: string;
+  title: string;
+  description: string;
+  category: string;
+  urgency: string;
+  article_count: number;
+  avg_sentiment: number;
+  geo_states: string[];
+  petition_suggestion: string | null;
+  petition_target: string | null;
+  petition_tone: string | null;
+  is_ai_suggested: boolean;
+  created_at: string;
+}
+
+interface GeneratedPetition {
+  title: string;
+  summary: string;
+  body_html: string;
+  suggested_tags: string[];
+  suggested_goal: number;
+}
+
+const URGENCY_COLORS: Record<string, string> = {
+  critical: '#ef4444', high: '#f59e0b', medium: '#3b82f6', low: '#6b7280',
+};
+
+export default function TopicsTab({ onViewPetition, onViewGrant }: { onViewPetition?: (id: number) => void; onViewGrant?: (id: string) => void }) {
+  const [topics, setTopics] = useState<Topic[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [generating, setGenerating] = useState<string | null>(null);
+  const [generated, setGenerated] = useState<Record<string, { petition: GeneratedPetition; petitionId: number }>>({});
+  const [expanded, setExpanded] = useState<string | null>(null);
+  const [discoveredGrants, setDiscoveredGrants] = useState<Record<string, Grant[]>>({});
+  const [findingGrants, setFindingGrants] = useState<string | null>(null);
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const res = await fetch('/api/pulse/topics');
+        if (res.ok) {
+          const data = await res.json();
+          setTopics(data.topics || []);
+        }
+      } catch (err) { console.error('Topics error:', err); }
+      finally { setLoading(false); }
+    })();
+  }, []);
+
+  async function generatePetition(topic: Topic) {
+    setGenerating(topic.id);
+    try {
+      // Step 1: Generate petition via Gemini
+      const genRes = await fetch('/api/petitions/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          topic: topic.title,
+          target: topic.petition_target || 'Elected Officials',
+          tone: topic.petition_tone || 'formal',
+          category: topic.category || 'policy',
+        }),
+      });
+      if (!genRes.ok) throw new Error('Generation failed');
+      const petition = await genRes.json();
+
+      // Step 2: Save as draft
+      const saveRes = await fetch('/api/petitions', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: petition.title,
+          summary: petition.summary,
+          body_html: petition.body_html,
+          body_text: petition.body_text || '',
+          target: petition.suggested_target || topic.petition_target,
+          category: topic.category,
+          tags: petition.suggested_tags || [],
+          signature_goal: petition.suggested_goal || 1000,
+          status: 'draft',
+        }),
+      });
+      if (!saveRes.ok) throw new Error('Save failed');
+      const saved = await saveRes.json();
+
+      setGenerated(prev => ({
+        ...prev,
+        [topic.id]: { petition, petitionId: saved.id || saved.petition?.id },
+      }));
+      setExpanded(topic.id);
+
+      // Step 3: Auto-discover related grants
+      try {
+        const grantRes = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+        if (grantRes.ok) {
+          const grantData = await grantRes.json();
+          if (grantData.grants?.length) {
+            setDiscoveredGrants(prev => ({ ...prev, [topic.id]: grantData.grants }));
+          }
+        }
+      } catch { /* grant discovery is optional */ }
+    } catch (err) {
+      console.error('Generate petition error:', err);
+    } finally {
+      setGenerating(null);
+    }
+  }
+
+  async function findGrantsForTopic(topic: Topic) {
+    setFindingGrants(topic.id);
+    try {
+      const res = await fetch('/api/grants-proxy/grants/discover', { method: 'POST' });
+      if (res.ok) {
+        const data = await res.json();
+        setDiscoveredGrants(prev => ({ ...prev, [topic.id]: data.grants || [] }));
+        setExpanded(topic.id);
+      }
+    } catch (err) {
+      console.error('Find grants error:', err);
+    } finally {
+      setFindingGrants(null);
+    }
+  }
+
+  if (loading) {
+    return (
+      <div style={{ padding: 32, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 8 }}>
+        <Loader2 size={18} style={{ animation: 'spin 1s linear infinite' }} /> Loading topics...
+      </div>
+    );
+  }
+
+  return (
+    <div style={{ padding: 24, maxWidth: 1000 }}>
+      <div style={{ marginBottom: 20 }}>
+        <h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>
+          Topics
+          <span style={{ fontSize: 13, fontWeight: 400, color: 'var(--color-text-muted)', marginLeft: 10 }}>
+            {topics.length} AI-suggested topics
+          </span>
+        </h2>
+      </div>
+
+      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
+        {topics.map(topic => {
+          const isGenerating = generating === topic.id;
+          const gen = generated[topic.id];
+          const isExpanded = expanded === topic.id;
+
+          return (
+            <div key={topic.id} style={{
+              borderRadius: 12, border: '1px solid var(--color-border)',
+              backgroundColor: 'var(--color-surface)', overflow: 'hidden',
+            }}>
+              {/* Topic Header */}
+              <div style={{ padding: '16px 20px' }}>
+                <div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
+                  <div style={{ flex: 1 }}>
+                    <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
+                      <h3 style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', margin: 0, lineHeight: 1.3 }}>
+                        {topic.title}
+                      </h3>
+                    </div>
+                    <p style={{ fontSize: 13, color: 'var(--color-text-muted)', margin: 0, lineHeight: 1.5 }}>
+                      {topic.description}
+                    </p>
+
+                    {/* Meta row */}
+                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginTop: 10 }}>
+                      {/* Urgency badge */}
+                      <span style={{
+                        fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 10,
+                        backgroundColor: (URGENCY_COLORS[topic.urgency] || '#6b7280') + '22',
+                        color: URGENCY_COLORS[topic.urgency] || '#6b7280',
+                        textTransform: 'uppercase', letterSpacing: '0.04em',
+                      }}>
+                        {topic.urgency}
+                      </span>
+
+                      {/* Category */}
+                      <span style={{
+                        fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
+                        backgroundColor: 'var(--color-bg)', color: 'var(--color-text-muted)',
+                      }}>
+                        {topic.category}
+                      </span>
+
+                      {/* Article count */}
+                      <span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
+                        <FileText size={12} /> {topic.article_count} article{topic.article_count !== 1 ? 's' : ''}
+                      </span>
+
+                      {/* Geo states */}
+                      {topic.geo_states?.length > 0 && (
+                        <span style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center', gap: 3 }}>
+                          <MapPin size={12} /> {topic.geo_states.join(', ')}
+                        </span>
+                      )}
+
+                      {/* Sentiment */}
+                      <span style={{
+                        fontSize: 11,
+                        color: topic.avg_sentiment < -0.3 ? '#ef4444' : topic.avg_sentiment > 0.3 ? '#22c55e' : 'var(--color-text-muted)',
+                      }}>
+                        Sentiment: {topic.avg_sentiment?.toFixed(2)}
+                      </span>
+                    </div>
+                  </div>
+
+                  {/* Action Buttons */}
+                  <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
+                    {gen ? (
+                      <div style={{ display: 'flex', gap: 6 }}>
+                        <button
+                          onClick={() => setExpanded(isExpanded ? null : topic.id)}
+                          style={{
+                            display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
+                            borderRadius: 8, border: '1px solid var(--color-border)',
+                            backgroundColor: 'var(--color-surface)', color: 'var(--color-text)',
+                            cursor: 'pointer', fontSize: 12,
+                          }}
+                        >
+                          {isExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
+                          Preview
+                        </button>
+                        <button
+                          onClick={() => onViewPetition?.(gen.petitionId)}
+                          style={{
+                            display: 'flex', alignItems: 'center', gap: 4, padding: '7px 12px',
+                            borderRadius: 8, border: 'none',
+                            backgroundColor: '#22c55e', color: '#fff',
+                            cursor: 'pointer', fontSize: 12, fontWeight: 600,
+                          }}
+                        >
+                          Edit in Petitions <ExternalLink size={12} />
+                        </button>
+                      </div>
+                    ) : (
+                      <button
+                        onClick={() => generatePetition(topic)}
+                        disabled={isGenerating || !!generating}
+                        style={{
+                          display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
+                          borderRadius: 8, border: 'none',
+                          background: isGenerating ? '#6b7280' : 'linear-gradient(135deg, #7c3aed, #6d28d9)',
+                          color: '#fff', cursor: isGenerating ? 'wait' : 'pointer',
+                          fontSize: 13, fontWeight: 600, opacity: generating && !isGenerating ? 0.5 : 1,
+                        }}
+                      >
+                        {isGenerating ? (
+                          <><Loader2 size={14} style={{ animation: 'spin 1s linear infinite' }} /> Generating...</>
+                        ) : (
+                          <><Sparkles size={14} /> Generate Petition</>
+                        )}
+                      </button>
+                    )}
+                    {/* Find Grants button */}
+                    <button
+                      onClick={() => findGrantsForTopic(topic)}
+                      disabled={findingGrants === topic.id}
+                      style={{
+                        display: 'flex', alignItems: 'center', gap: 5, padding: '6px 14px',
+                        borderRadius: 8, border: '1px solid #059669',
+                        backgroundColor: 'transparent', color: '#10b981',
+                        cursor: findingGrants === topic.id ? 'wait' : 'pointer',
+                        fontSize: 12, fontWeight: 600,
+                        opacity: findingGrants === topic.id ? 0.7 : 1,
+                      }}
+                    >
+                      {findingGrants === topic.id
+                        ? <><Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> Finding…</>
+                        : <><DollarSign size={12} /> Find Grants</>
+                      }
+                    </button>
+                  </div>
+                </div>
+              </div>
+
+              {/* Expanded Grants Only (no petition yet) */}
+              {!gen && isExpanded && discoveredGrants[topic.id]?.length > 0 && (
+                <div style={{
+                  padding: '16px 20px', borderTop: '1px solid var(--color-border)',
+                  backgroundColor: 'var(--color-bg)',
+                }}>
+                  <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+                    Discovered Grants ({discoveredGrants[topic.id].length})
+                  </div>
+                  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+                    {discoveredGrants[topic.id].slice(0, 5).map(g => (
+                      <div key={g.id}
+                        onClick={() => onViewGrant?.(g.id)}
+                        style={{
+                          display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
+                          borderRadius: 8, backgroundColor: 'var(--color-surface)',
+                          border: '1px solid var(--color-border)', cursor: 'pointer',
+                        }}
+                        onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
+                        onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+                      >
+                        <DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
+                        <div style={{ flex: 1, minWidth: 0 }}>
+                          <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
+                            {g.title}
+                          </div>
+                          <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+                            {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+                          </div>
+                        </div>
+                        {g.ai_fit_score != null && (
+                          <span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
+                            {Math.round(g.ai_fit_score * 100)}%
+                          </span>
+                        )}
+                        <ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {/* Expanded Petition Preview */}
+              {gen && isExpanded && (
+                <div style={{
+                  padding: '16px 20px', borderTop: '1px solid var(--color-border)',
+                  backgroundColor: 'var(--color-bg)',
+                }}>
+                  <div style={{ fontSize: 11, fontWeight: 600, color: '#22c55e', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 8 }}>
+                    Generated Petition — Saved as Draft
+                  </div>
+                  <h4 style={{ fontSize: 16, fontWeight: 700, color: 'var(--color-text)', margin: '0 0 8px' }}>
+                    {gen.petition.title}
+                  </h4>
+                  <p style={{ fontSize: 13, color: 'var(--color-text-secondary)', margin: '0 0 12px', lineHeight: 1.6 }}>
+                    {gen.petition.summary}
+                  </p>
+                  {gen.petition.suggested_tags?.length > 0 && (
+                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 12 }}>
+                      {gen.petition.suggested_tags.map((tag, i) => (
+                        <span key={i} style={{
+                          fontSize: 10, padding: '2px 8px', borderRadius: 10,
+                          backgroundColor: '#7c3aed22', color: '#a78bfa',
+                        }}>{tag}</span>
+                      ))}
+                    </div>
+                  )}
+
+                  {/* Discovered Grants inline */}
+                  {discoveredGrants[topic.id]?.length > 0 && (
+                    <div>
+                      <div style={{ fontSize: 11, fontWeight: 600, color: '#10b981', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 6 }}>
+                        Related Grants Found ({discoveredGrants[topic.id].length})
+                      </div>
+                      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+                        {discoveredGrants[topic.id].slice(0, 5).map(g => (
+                          <div key={g.id}
+                            onClick={() => onViewGrant?.(g.id)}
+                            style={{
+                              display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
+                              borderRadius: 8, backgroundColor: 'var(--color-surface)',
+                              border: '1px solid var(--color-border)', cursor: 'pointer',
+                              transition: 'border-color 0.15s',
+                            }}
+                            onMouseEnter={e => { e.currentTarget.style.borderColor = '#059669'; }}
+                            onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
+                          >
+                            <DollarSign size={14} style={{ color: '#10b981', flexShrink: 0 }} />
+                            <div style={{ flex: 1, minWidth: 0 }}>
+                              <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
+                                {g.title}
+                              </div>
+                              <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
+                                {g.funder} · {formatAmount(g.amount_min, g.amount_max)}
+                              </div>
+                            </div>
+                            {g.ai_fit_score != null && (
+                              <span style={{ fontSize: 11, fontWeight: 600, color: '#f59e0b', flexShrink: 0 }}>
+                                {Math.round(g.ai_fit_score * 100)}%
+                              </span>
+                            )}
+                            <ExternalLink size={12} style={{ color: 'var(--color-text-muted)', flexShrink: 0 }} />
+                          </div>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+
+      {topics.length === 0 && (
+        <div style={{
+          padding: 40, textAlign: 'center',
+          color: 'var(--color-text-muted)', fontSize: 14,
+        }}>
+          <AlertTriangle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
+          <div>No topics found. Topics are generated when news is fetched.</div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/components/trends/TrendsTab.tsx b/components/trends/TrendsTab.tsx
new file mode 100644
index 0000000..5ff9a60
--- /dev/null
+++ b/components/trends/TrendsTab.tsx
@@ -0,0 +1,534 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { SkeletonList } from '../Skeleton';
+import {
+  TrendingUp, Sparkles, RefreshCw, Loader2, Clock,
+  Megaphone, X, Plus, Target,
+} from 'lucide-react';
+import { useToast } from '../ToastProvider';
+import SortDropdown from '../shared/SortDropdown';
+import { useClientSort, SortConfig } from '@/hooks/useClientSort';
+
+const TREND_SORT_OPTIONS = [
+  { value: 'engagement_desc', label: 'Most Engaging' },
+  { value: 'date_desc', label: 'Newest First' },
+  { value: 'date_asc', label: 'Oldest First' },
+  { value: 'sentiment', label: 'Sentiment A-Z' },
+];
+
+const TREND_SORT_CONFIGS: Record<string, SortConfig> = {
+  engagement_desc: { key: 'engagement_score', direction: 'desc', type: 'number' },
+  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
+  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
+  sentiment: { key: 'sentiment', direction: 'asc', type: 'string' },
+};
+
+/* ─── Types ─────────────────────────────────────────────────── */
+interface TrendingTopic {
+  id: string;
+  source: string;
+  source_url: string | null;
+  title: string;
+  content: string | null;
+  engagement_score: number | null;
+  sentiment: string | null;
+  category: string | null;
+  tags: string[] | null;
+  is_used: boolean;
+  expires_at: string | null;
+  created_at: string;
+}
+
+/* ─── Component ─────────────────────────────────────────────── */
+export default function TrendsTab() {
+  const { addToast } = useToast();
+  const [topics, setTopics] = useState<TrendingTopic[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [discovering, setDiscovering] = useState(false);
+  const [categoryFilter, setCategoryFilter] = useState('all');
+  const [sortBy, setSortBy] = useState('engagement_desc');
+  const sortedTopics = useClientSort(topics, sortBy, TREND_SORT_CONFIGS);
+
+  // Petition creation modal (pre-filled from trend)
+  const [createFromTrend, setCreateFromTrend] = useState<TrendingTopic | null>(null);
+  const [petTopic, setPetTopic] = useState('');
+  const [petTarget, setPetTarget] = useState('');
+  const [petTone, setPetTone] = useState('urgent');
+  const [petCategory, setPetCategory] = useState('policy');
+  const [generating, setGenerating] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [generated, setGenerated] = useState<{
+    title: string; summary: string; body_html: string; body_text: string;
+    suggested_target: string; suggested_tags: string[]; suggested_goal: number;
+  } | null>(null);
+
+  /* ── Fetch ──────────────────────────────────────────────────── */
+  const fetchTopics = useCallback(async () => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams();
+      if (categoryFilter !== 'all') params.set('category', categoryFilter);
+      const res = await fetch(`/api/trending?${params.toString()}`);
+      if (res.ok) {
+        const data = await res.json();
+        setTopics(data.rows || []);
+      }
+    } catch (err) {
+      console.error('Fetch trends error:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [categoryFilter]);
+
+  useEffect(() => { fetchTopics(); }, [fetchTopics]);
+
+  /* ── Escape key handler ───────────────────────────────────────── */
+  useEffect(() => {
+    if (!createFromTrend) return;
+    const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setCreateFromTrend(null); };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [createFromTrend]);
+
+  /* ── AI Discover ────────────────────────────────────────────── */
+  async function handleDiscover() {
+    setDiscovering(true);
+    try {
+      const res = await fetch('/api/trending/discover', { method: 'POST' });
+      if (res.ok) {
+        fetchTopics();
+        addToast('Trends discovered', 'success');
+      } else {
+        addToast('Failed to discover trends', 'error');
+      }
+    } catch (err) {
+      console.error('Discover error:', err);
+      addToast('Failed to discover trends', 'error');
+    } finally {
+      setDiscovering(false);
+    }
+  }
+
+  /* ── Create Petition from Trend ─────────────────────────────── */
+  function openCreateFromTrend(topic: TrendingTopic) {
+    setCreateFromTrend(topic);
+    setPetTopic(topic.title);
+    setPetCategory(topic.category || 'policy');
+    setPetTarget('');
+    setPetTone('urgent');
+    setGenerated(null);
+  }
+
+  async function handleGenerate() {
+    setGenerating(true);
+    setGenerated(null);
+    try {
+      const res = await fetch('/api/petitions/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ topic: petTopic, target: petTarget, tone: petTone, category: petCategory }),
+      });
+      if (res.ok) {
+        const data = await res.json();
+        setGenerated(data);
+        addToast('Petition generated', 'success');
+      } else {
+        addToast('Generation failed', 'error');
+      }
+    } catch (err) {
+      console.error('Generate error:', err);
+      addToast('Generation failed', 'error');
+    } finally {
+      setGenerating(false);
+    }
+  }
+
+  async function handleSavePetition() {
+    if (!generated) return;
+    setSaving(true);
+    try {
+      const res = await fetch('/api/petitions', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          title: generated.title,
+          summary: generated.summary,
+          body_html: generated.body_html,
+          body_text: generated.body_text,
+          target: generated.suggested_target,
+          category: petCategory,
+          tags: generated.suggested_tags,
+          signature_goal: generated.suggested_goal,
+          status: 'draft',
+        }),
+      });
+      if (res.ok) {
+        setCreateFromTrend(null);
+        setGenerated(null);
+        addToast('Petition saved as draft', 'success');
+      } else {
+        addToast('Failed to save petition', 'error');
+      }
+    } catch (err) {
+      console.error('Save petition error:', err);
+      addToast('Failed to save petition', 'error');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  /* ── Helpers ────────────────────────────────────────────────── */
+  const categories = ['all', 'debt_relief', 'policy', 'education_access', 'consumer_protection', 'federal_budget'];
+
+  const categoryLabels: Record<string, string> = {
+    all: 'All',
+    debt_relief: 'Debt Relief',
+    policy: 'Policy',
+    education_access: 'Education Access',
+    consumer_protection: 'Consumer Protection',
+    federal_budget: 'Federal Budget',
+  };
+
+  const sentimentColors: Record<string, { bg: string; text: string; border: string }> = {
+    positive: { bg: 'rgba(34,197,94,0.15)', text: 'var(--color-success)', border: 'rgba(34,197,94,0.3)' },
+    negative: { bg: 'rgba(239,68,68,0.15)', text: 'var(--color-error)', border: 'rgba(239,68,68,0.3)' },
+    neutral: { bg: 'rgba(161,161,170,0.15)', text: 'var(--color-text-secondary)', border: 'rgba(161,161,170,0.3)' },
+    mixed: { bg: 'rgba(245,158,11,0.15)', text: 'var(--color-warning)', border: 'rgba(245,158,11,0.3)' },
+  };
+
+  const sourceColors: Record<string, string> = {
+    'Twitter/X': '#1DA1F2',
+    'Reddit': '#FF4500',
+    'News': '#22c55e',
+    'Congress.gov': '#3b82f6',
+    'Change.org': '#E84444',
+    'TikTok': '#ff2d55',
+    'Instagram': '#C13584',
+  };
+
+  const expiresIn = (d: string | null) => {
+    if (!d) return null;
+    const diff = new Date(d).getTime() - Date.now();
+    if (diff <= 0) return 'Expired';
+    const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+    const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+    if (days > 0) return `${days}d ${hours}h left`;
+    return `${hours}h left`;
+  };
+
+  function stripHtml(html: string): string {
+    return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+  }
+
+  /* ─── Render ────────────────────────────────────────────────── */
+  return (
+    <div style={{ padding: '24px' }}>
+      {/* Header */}
+      <div className="flex items-center justify-between mb-6">
+        <div>
+          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
+            Trending Topics
+          </h2>
+          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
+            AI-curated topics ready for petition creation
+          </p>
+        </div>
+        <button
+          className="btn btn-primary"
+          onClick={handleDiscover}
+          disabled={discovering}
+        >
+          {discovering ? (
+            <><Loader2 size={16} className="animate-spin" /> Discovering...</>
+          ) : (
+            <><Sparkles size={16} /> AI Discover Trends</>
+          )}
+        </button>
+      </div>
+
+      {/* Sort + Category Filter */}
+      <div className="flex items-center gap-2 mb-6 flex-wrap">
+        <SortDropdown options={TREND_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
+        {categories.map((c) => (
+          <button
+            key={c}
+            onClick={() => setCategoryFilter(c)}
+            className={`btn btn-sm ${categoryFilter === c ? 'btn-primary' : 'btn-ghost'}`}
+          >
+            {categoryLabels[c] || c}
+          </button>
+        ))}
+      </div>
+
+      {/* Loading */}
+      {loading && (
+        <SkeletonList count={4} />
+      )}
+
+      {/* Empty State */}
+      {!loading && topics.length === 0 && (
+        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
+          <div
+            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
+            style={{
+              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
+              border: '1px solid rgba(124, 58, 237, 0.2)',
+            }}
+          >
+            <TrendingUp size={28} style={{ color: 'var(--color-primary)' }} />
+          </div>
+          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>
+            No trending topics yet
+          </h3>
+          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
+            Use AI to discover trending topics about student debt and education policy.
+          </p>
+          <button className="btn btn-primary" onClick={handleDiscover} disabled={discovering}>
+            {discovering ? (
+              <><Loader2 size={16} className="animate-spin" /> Discovering...</>
+            ) : (
+              <><Sparkles size={16} /> Discover Trends Now</>
+            )}
+          </button>
+
+          {/* How It Works */}
+          <div className="mt-10 grid grid-cols-1 sm:grid-cols-3 gap-4 w-full max-w-2xl">
+            {[
+              { title: 'Discover', desc: 'AI scans news, social media, and gov sites for trending civic issues' },
+              { title: 'Analyze', desc: 'Each topic is scored for engagement potential and sentiment' },
+              { title: 'Create', desc: 'One click to generate a petition draft from any trending topic' },
+            ].map((step, i) => (
+              <div key={i} className="card-elevated" style={{ padding: '16px', textAlign: 'left' }}>
+                <div className="text-xs font-bold mb-2" style={{ color: 'var(--color-primary)' }}>
+                  Step {i + 1}
+                </div>
+                <div className="text-sm font-semibold mb-1" style={{ color: 'var(--color-text)' }}>
+                  {step.title}
+                </div>
+                <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
+                  {step.desc}
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Topic Cards */}
+      {!loading && sortedTopics.length > 0 && (
+        <div className="flex flex-col gap-3">
+          {sortedTopics.map((t) => {
+            const sent = sentimentColors[t.sentiment || 'neutral'] || sentimentColors.neutral;
+            const srcColor = sourceColors[t.source] || 'var(--color-text-muted)';
+            const expiry = expiresIn(t.expires_at);
+            const score = t.engagement_score || 0;
+            return (
+              <div key={t.id} className="card">
+                <div className="flex items-start gap-4">
+                  <div className="flex-1 min-w-0">
+                    {/* Title + Badges */}
+                    <div className="flex items-center gap-2 mb-2 flex-wrap">
+                      <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
+                        {t.title}
+                      </h3>
+                      {/* Source badge */}
+                      <span
+                        className="badge"
+                        style={{
+                          backgroundColor: `${srcColor}20`,
+                          color: srcColor,
+                          border: `1px solid ${srcColor}40`,
+                        }}
+                      >
+                        {t.source}
+                      </span>
+                      {/* Sentiment badge */}
+                      <span
+                        className="badge"
+                        style={{
+                          backgroundColor: sent.bg,
+                          color: sent.text,
+                          border: `1px solid ${sent.border}`,
+                        }}
+                      >
+                        {t.sentiment || 'neutral'}
+                      </span>
+                      {/* Category */}
+                      {t.category && (
+                        <span
+                          className="badge"
+                          style={{
+                            backgroundColor: 'rgba(124, 58, 237, 0.1)',
+                            color: 'var(--color-secondary)',
+                            border: '1px solid rgba(124, 58, 237, 0.2)',
+                          }}
+                        >
+                          {categoryLabels[t.category] || t.category}
+                        </span>
+                      )}
+                    </div>
+
+                    {/* Content */}
+                    {t.content && (
+                      <p className="text-xs mb-3 line-clamp-2" style={{ color: 'var(--color-text-muted)' }}>
+                        {t.content}
+                      </p>
+                    )}
+
+                    {/* Engagement Score Bar */}
+                    <div className="flex items-center gap-3 mb-2">
+                      <span className="text-xs" style={{ color: 'var(--color-text-muted)', width: 80 }}>
+                        Engagement
+                      </span>
+                      <div
+                        style={{
+                          flex: 1, maxWidth: 200, height: 6, borderRadius: 3,
+                          backgroundColor: 'var(--color-surface-el)', overflow: 'hidden',
+                        }}
+                      >
+                        <div
+                          style={{
+                            width: `${score}%`, height: '100%', borderRadius: 3,
+                            background: score > 70 ? 'linear-gradient(90deg, #22c55e, #4ade80)' :
+                              score > 40 ? 'linear-gradient(90deg, #f59e0b, #fbbf24)' :
+                                'linear-gradient(90deg, #ef4444, #f87171)',
+                          }}
+                        />
+                      </div>
+                      <span className="text-xs font-semibold" style={{ color: 'var(--color-text-secondary)', width: 30 }}>
+                        {score}
+                      </span>
+                    </div>
+
+                    {/* Tags + Expiry */}
+                    <div className="flex items-center gap-2 flex-wrap">
+                      {t.tags && t.tags.map((tag, i) => (
+                        <span key={i} className="text-xs" style={{
+                          color: 'var(--color-secondary)',
+                          padding: '1px 6px', borderRadius: 9999,
+                          backgroundColor: 'rgba(124, 58, 237, 0.08)',
+                        }}>
+                          {tag}
+                        </span>
+                      ))}
+                      {expiry && (
+                        <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--color-text-muted)', marginLeft: 'auto' }}>
+                          <Clock size={11} /> {expiry}
+                        </span>
+                      )}
+                    </div>
+                  </div>
+
+                  {/* Action */}
+                  <button
+                    className="btn btn-sm btn-primary shrink-0"
+                    onClick={() => openCreateFromTrend(t)}
+                  >
+                    <Megaphone size={14} />
+                    Create Petition
+                  </button>
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      )}
+
+      {/* ─── Create Petition from Trend Modal ────────────────────── */}
+      {createFromTrend && (
+        <div
+          style={{
+            position: 'fixed', inset: 0, zIndex: 50,
+            display: 'flex', alignItems: 'center', justifyContent: 'center',
+            backgroundColor: 'rgba(0,0,0,0.6)',
+            backdropFilter: 'blur(4px)',
+          }}
+          onClick={(e) => { if (e.target === e.currentTarget) setCreateFromTrend(null); }}
+        >
+          <div
+            style={{
+              width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
+              backgroundColor: 'var(--color-surface)',
+              border: '1px solid var(--color-border)',
+              borderRadius: 12, padding: 24,
+            }}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
+                Create Petition from Trend
+              </h3>
+              <button className="btn btn-ghost btn-sm" onClick={() => setCreateFromTrend(null)}>
+                <X size={16} />
+              </button>
+            </div>
+
+            {!generated ? (
+              <div className="flex flex-col gap-4">
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Topic</label>
+                  <input className="input" value={petTopic} onChange={(e) => setPetTopic(e.target.value)} />
+                </div>
+                <div>
+                  <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Target</label>
+                  <input className="input" placeholder="e.g., Congress" value={petTarget} onChange={(e) => setPetTarget(e.target.value)} />
+                </div>
+                <div className="grid grid-cols-2 gap-4">
+                  <div>
+                    <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Tone</label>
+                    <select className="input" value={petTone} onChange={(e) => setPetTone(e.target.value)}>
+                      <option value="urgent">Urgent</option>
+                      <option value="formal">Formal</option>
+                      <option value="passionate">Passionate</option>
+                      <option value="grassroots">Grassroots</option>
+                    </select>
+                  </div>
+                  <div>
+                    <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Category</label>
+                    <select className="input" value={petCategory} onChange={(e) => setPetCategory(e.target.value)}>
+                      <option value="debt_relief">Debt Relief</option>
+                      <option value="policy">Policy</option>
+                      <option value="education_access">Education Access</option>
+                      <option value="consumer_protection">Consumer Protection</option>
+                      <option value="federal_budget">Federal Budget</option>
+                    </select>
+                  </div>
+                </div>
+                <button className="btn btn-primary w-full" onClick={handleGenerate} disabled={generating}>
+                  {generating ? <><Loader2 size={16} className="animate-spin" /> Generating...</> : <><Sparkles size={16} /> Generate Petition</>}
+                </button>
+              </div>
+            ) : (
+              <div className="flex flex-col gap-4">
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Title</div>
+                  <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{generated.title}</div>
+                </div>
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Summary</div>
+                  <div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>{generated.summary}</div>
+                </div>
+                <div>
+                  <div className="text-xs font-medium mb-1" style={{ color: 'var(--color-text-muted)' }}>Body Preview</div>
+                  <div className="text-xs" style={{
+                    color: 'var(--color-text-muted)', maxHeight: 160, overflow: 'auto',
+                    padding: 8, borderRadius: 6, backgroundColor: 'var(--color-surface-el)',
+                    whiteSpace: 'pre-wrap',
+                  }}>
+                    {stripHtml(generated.body_html)}
+                  </div>
+                </div>
+                <div className="flex items-center gap-3">
+                  <button className="btn btn-primary flex-1" onClick={handleSavePetition} disabled={saving}>
+                    {saving ? <><Loader2 size={16} className="animate-spin" /> Saving...</> : <><Plus size={16} /> Save as Draft</>}
+                  </button>
+                  <button className="btn btn-ghost" onClick={() => setGenerated(null)}>Regenerate</button>
+                </div>
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
diff --git a/db/orbit-schema.sql b/db/orbit-schema.sql
new file mode 100644
index 0000000..9829617
--- /dev/null
+++ b/db/orbit-schema.sql
@@ -0,0 +1,122 @@
+-- Orbit: Kalshi <-> Petition Bridge + RSS News Intelligence
+-- Part of Patty - The Petition Specialist
+-- Database: postgresql://dw_admin@127.0.0.1:5432/  # password in .env.local (DATABASE_URL)
+--   patty
+
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Kalshi prediction markets synced from Ken agent
+CREATE TABLE IF NOT EXISTS orbit_markets (
+  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+  ticker TEXT NOT NULL UNIQUE,
+  event_ticker TEXT,
+  title TEXT NOT NULL,
+  subtitle TEXT,
+  category TEXT,
+  yes_bid INT,
+  yes_ask INT,
+  no_bid INT,
+  no_ask INT,
+  last_price INT,
+  volume INT,
+  volume_24h INT,
+  open_interest INT,
+  status TEXT DEFAULT 'open',
+  close_time TIMESTAMPTZ,
+  petition_relevance REAL DEFAULT 0, -- 0-100 AI-scored relevance to advocacy
+  suggested_petition TEXT, -- AI-generated petition angle
+  suggested_target TEXT,
+  raw_data JSONB,
+  last_synced_at TIMESTAMPTZ DEFAULT NOW(),
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Links between markets and petitions
+CREATE TABLE IF NOT EXISTS orbit_links (
+  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+  market_id UUID REFERENCES orbit_markets(id) ON DELETE CASCADE,
+  petition_id UUID REFERENCES petitions(id) ON DELETE CASCADE,
+  link_type TEXT NOT NULL DEFAULT 'influence', -- influence, counter, support
+  strength REAL DEFAULT 0.5, -- 0-1 connection strength
+  ai_reasoning TEXT,
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  UNIQUE(market_id, petition_id)
+);
+
+-- RSS/Atom feed sources
+CREATE TABLE IF NOT EXISTS orbit_feeds (
+  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+  name TEXT NOT NULL,
+  url TEXT NOT NULL UNIQUE,
+  feed_type TEXT NOT NULL DEFAULT 'rss', -- rss, atom, json
+  category TEXT DEFAULT 'national', -- national, local, wire
+  station TEXT, -- CNN, Fox, NBC, local call sign
+  market TEXT, -- DMA market for local (New York, Los Angeles, etc.)
+  is_active BOOLEAN DEFAULT TRUE,
+  last_fetched_at TIMESTAMPTZ,
+  fetch_interval_min INT DEFAULT 30,
+  article_count INT DEFAULT 0,
+  created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Articles fetched from RSS feeds
+CREATE TABLE IF NOT EXISTS orbit_articles (
+  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+  feed_id UUID REFERENCES orbit_feeds(id) ON DELETE CASCADE,
+  title TEXT NOT NULL,
+  url TEXT NOT NULL,
+  summary TEXT,
+  published_at TIMESTAMPTZ,
+  sentiment TEXT, -- positive, negative, neutral, mixed
+  sentiment_score REAL, -- -1 to 1
+  relevance_score REAL DEFAULT 0, -- 0-100 relevance to petition topics
+  matched_markets TEXT[], -- Kalshi market tickers this article relates to
+  matched_petitions UUID[], -- Petition IDs this article relates to
+  tags TEXT[],
+  raw_data JSONB,
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  UNIQUE(feed_id, url)
+);
+
+-- Cross-platform petition posts
+CREATE TABLE IF NOT EXISTS orbit_posts (
+  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+  petition_id UUID REFERENCES petitions(id) ON DELETE CASCADE,
+  platform TEXT NOT NULL, -- moveon, change_org, we_the_people, custom
+  external_url TEXT,
+  status TEXT DEFAULT 'draft', -- draft, posted, failed, archived
+  post_data JSONB, -- platform-specific payload
+  response_data JSONB, -- platform response
+  posted_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_category ON orbit_markets(category);
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_relevance ON orbit_markets(petition_relevance DESC);
+CREATE INDEX IF NOT EXISTS idx_orbit_markets_status ON orbit_markets(status);
+CREATE INDEX IF NOT EXISTS idx_orbit_links_market ON orbit_links(market_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_links_petition ON orbit_links(petition_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_sentiment ON orbit_articles(sentiment_score);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_published ON orbit_articles(published_at DESC);
+CREATE INDEX IF NOT EXISTS idx_orbit_articles_feed ON orbit_articles(feed_id);
+CREATE INDEX IF NOT EXISTS idx_orbit_feeds_active ON orbit_feeds(is_active);
+CREATE INDEX IF NOT EXISTS idx_orbit_posts_platform ON orbit_posts(platform);
+CREATE INDEX IF NOT EXISTS idx_orbit_posts_petition ON orbit_posts(petition_id);
+
+-- Trigger for orbit_markets updated_at
+CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$
+BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DO $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1 FROM pg_trigger WHERE tgname = 'tr_orbit_markets_updated'
+  ) THEN
+    CREATE TRIGGER tr_orbit_markets_updated
+      BEFORE UPDATE ON orbit_markets
+      FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+  END IF;
+END $$;
diff --git a/db/orbit-seed-feeds.sql b/db/orbit-seed-feeds.sql
new file mode 100644
index 0000000..3178b20
--- /dev/null
+++ b/db/orbit-seed-feeds.sql
@@ -0,0 +1,33 @@
+-- Orbit: Seed RSS Feeds
+-- National TV, Wire Services, and Local TV (top 10 DMAs)
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station) VALUES
+  ('CNN Top Stories', 'https://rss.cnn.com/rss/cnn_topstories.rss', 'rss', 'national', 'CNN'),
+  ('CNN Politics', 'https://rss.cnn.com/rss/cnn_allpolitics.rss', 'rss', 'national', 'CNN'),
+  ('Fox News Latest', 'https://moxie.foxnews.com/google-publisher/latest.xml', 'rss', 'national', 'Fox News'),
+  ('Fox News Politics', 'https://moxie.foxnews.com/google-publisher/politics.xml', 'rss', 'national', 'Fox News'),
+  ('MSNBC Latest', 'https://www.msnbc.com/feeds/latest', 'rss', 'national', 'MSNBC'),
+  ('NBC News', 'https://feeds.nbcnews.com/nbcnews/public/news', 'rss', 'national', 'NBC News'),
+  ('ABC News Top Stories', 'https://abcnews.go.com/abcnews/topstories', 'rss', 'national', 'ABC News'),
+  ('CBS News', 'https://www.cbsnews.com/latest/rss/main', 'rss', 'national', 'CBS News'),
+  ('PBS NewsHour', 'https://www.pbs.org/newshour/feeds/rss/headlines', 'rss', 'national', 'PBS'),
+  ('NPR News', 'https://feeds.npr.org/1001/rss.xml', 'rss', 'national', 'NPR')
+ON CONFLICT (url) DO NOTHING;
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station) VALUES
+  ('AP News', 'https://rsshub.app/apnews/topics/apf-topnews', 'rss', 'wire', 'AP'),
+  ('Reuters', 'https://www.reutersagency.com/feed/', 'rss', 'wire', 'Reuters')
+ON CONFLICT (url) DO NOTHING;
+
+INSERT INTO orbit_feeds (name, url, feed_type, category, station, market) VALUES
+  ('WABC New York', 'https://abc7ny.com/feed/', 'rss', 'local', 'WABC', 'New York'),
+  ('KABC Los Angeles', 'https://abc7.com/feed/', 'rss', 'local', 'KABC', 'Los Angeles'),
+  ('WLS Chicago', 'https://abc7chicago.com/feed/', 'rss', 'local', 'WLS', 'Chicago'),
+  ('KTRK Houston', 'https://abc13.com/feed/', 'rss', 'local', 'KTRK', 'Houston'),
+  ('KXAS Dallas', 'https://www.nbcdfw.com/news/local/?rss=y', 'rss', 'local', 'KXAS', 'Dallas'),
+  ('WPVI Philadelphia', 'https://6abc.com/feed/', 'rss', 'local', 'WPVI', 'Philadelphia'),
+  ('WJLA Washington DC', 'https://wjla.com/feed', 'rss', 'local', 'WJLA', 'Washington DC'),
+  ('WSB Atlanta', 'https://www.wsbtv.com/news/local/?rss=y', 'rss', 'local', 'WSB', 'Atlanta'),
+  ('KPIX San Francisco', 'https://www.cbsnews.com/sanfrancisco/latest/rss/main', 'rss', 'local', 'KPIX', 'San Francisco'),
+  ('WFTS Tampa', 'https://www.abcactionnews.com/feeds/rss', 'rss', 'local', 'WFTS', 'Tampa')
+ON CONFLICT (url) DO NOTHING;
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..c908501
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,159 @@
+-- Patty - The Petition Specialist
+-- Database Schema
+-- Database: postgresql://dw_admin@127.0.0.1:5432/  # password in .env.local (DATABASE_URL)
+--   patty
+
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+CREATE TABLE users (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    google_id TEXT UNIQUE,
+    email TEXT NOT NULL UNIQUE,
+    display_name TEXT,
+    avatar_url TEXT,
+    org_name TEXT,
+    org_type TEXT CHECK (org_type IN ('individual', '501c3', '501c4', 'for_profit', 'other')),
+    address TEXT,
+    phone TEXT,
+    website TEXT,
+    gmail_tokens JSONB,
+    gdrive_tokens JSONB,
+    onboarding_step INTEGER DEFAULT 0,
+    is_admin BOOLEAN DEFAULT false,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE petitions (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    title TEXT NOT NULL,
+    slug TEXT UNIQUE NOT NULL,
+    summary TEXT,
+    body_html TEXT NOT NULL,
+    body_text TEXT,
+    target TEXT,
+    target_emails TEXT[],
+    category TEXT,
+    tags TEXT[],
+    image_url TEXT,
+    status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'closed', 'delivered')),
+    signature_goal INTEGER DEFAULT 100,
+    signature_count INTEGER DEFAULT 0,
+    share_count INTEGER DEFAULT 0,
+    org_type TEXT,
+    compliance_flags JSONB DEFAULT '{}',
+    ai_trend_score REAL,
+    ai_source TEXT,
+    ai_topic_data JSONB,
+    distribution_platform TEXT DEFAULT 'internal',
+    external_url TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_petitions_user ON petitions(user_id);
+CREATE INDEX idx_petitions_status ON petitions(status);
+CREATE INDEX idx_petitions_slug ON petitions(slug);
+
+CREATE TABLE signatures (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    petition_id UUID NOT NULL REFERENCES petitions(id) ON DELETE CASCADE,
+    signer_email TEXT NOT NULL,
+    signer_name TEXT,
+    signer_zip TEXT,
+    signer_comment TEXT,
+    is_public BOOLEAN DEFAULT true,
+    ip_address TEXT,
+    source TEXT DEFAULT 'web',
+    opted_in_email BOOLEAN DEFAULT false,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    UNIQUE(petition_id, signer_email)
+);
+
+CREATE INDEX idx_signatures_petition ON signatures(petition_id);
+
+CREATE TABLE email_subscribers (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+    email TEXT NOT NULL,
+    name TEXT,
+    zip_code TEXT,
+    source TEXT DEFAULT 'signup',
+    petition_ids UUID[],
+    tags TEXT[],
+    is_active BOOLEAN DEFAULT true,
+    unsubscribed_at TIMESTAMPTZ,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE email_campaigns (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    petition_id UUID REFERENCES petitions(id) ON DELETE SET NULL,
+    subject TEXT NOT NULL,
+    body_html TEXT NOT NULL,
+    body_text TEXT,
+    status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'failed')),
+    send_to TEXT DEFAULT 'all',
+    recipient_count INTEGER DEFAULT 0,
+    open_count INTEGER DEFAULT 0,
+    click_count INTEGER DEFAULT 0,
+    scheduled_at TIMESTAMPTZ,
+    sent_at TIMESTAMPTZ,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE trending_topics (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    source TEXT NOT NULL,
+    source_url TEXT,
+    title TEXT NOT NULL,
+    content TEXT,
+    engagement_score REAL,
+    sentiment TEXT,
+    category TEXT,
+    tags TEXT[],
+    raw_data JSONB,
+    is_used BOOLEAN DEFAULT false,
+    expires_at TIMESTAMPTZ,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_trends_source ON trending_topics(source);
+CREATE INDEX idx_trends_score ON trending_topics(engagement_score DESC);
+
+CREATE TABLE petition_templates (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    title TEXT NOT NULL,
+    category TEXT,
+    body_html TEXT NOT NULL,
+    body_text TEXT,
+    tags TEXT[],
+    usage_count INTEGER DEFAULT 0,
+    is_featured BOOLEAN DEFAULT false,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE audit_events (
+    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+    event_type TEXT NOT NULL,
+    entity_type TEXT NOT NULL,
+    entity_id UUID,
+    actor TEXT DEFAULT 'system',
+    metadata JSONB,
+    ip_address TEXT,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Triggers
+CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
+
+CREATE TRIGGER tr_users_updated BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_petitions_updated BEFORE UPDATE ON petitions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_subscribers_updated BEFORE UPDATE ON email_subscribers FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_campaigns_updated BEFORE UPDATE ON email_campaigns FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+CREATE TRIGGER tr_templates_updated BEFORE UPDATE ON petition_templates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..5c2d7fe
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,15 @@
+module.exports = {
+  apps: [{
+    name: 'patty-petitions',
+    script: 'node_modules/.bin/next',
+    args: 'start -p 7460',
+    cwd: '/root/Projects/Patty',
+    env: {
+      NODE_ENV: 'production',
+      PORT: '7460',
+    },
+    max_memory_restart: '512M',
+    instances: 1,
+    autorestart: true,
+  }],
+};
diff --git a/hooks/useClientSort.ts b/hooks/useClientSort.ts
new file mode 100644
index 0000000..8cd5401
--- /dev/null
+++ b/hooks/useClientSort.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+
+export interface SortConfig {
+  key: string;
+  direction: 'asc' | 'desc';
+  type: 'string' | 'number' | 'date';
+}
+
+export function useClientSort<T>(
+  items: T[],
+  sortBy: string,
+  configs: Record<string, SortConfig>
+): T[] {
+  return useMemo(() => {
+    const config = configs[sortBy];
+    if (!config || !items.length) return items;
+    return [...items].sort((a, b) => {
+      const aVal = (a as Record<string, unknown>)[config.key];
+      const bVal = (b as Record<string, unknown>)[config.key];
+      if (aVal == null && bVal == null) return 0;
+      if (aVal == null) return 1;
+      if (bVal == null) return -1;
+      let cmp = 0;
+      if (config.type === 'date') {
+        cmp = new Date(aVal as string).getTime() - new Date(bVal as string).getTime();
+      } else if (config.type === 'number') {
+        cmp = (Number(aVal) || 0) - (Number(bVal) || 0);
+      } else {
+        cmp = String(aVal).localeCompare(String(bVal));
+      }
+      return config.direction === 'desc' ? -cmp : cmp;
+    });
+  }, [items, sortBy, configs]);
+}
diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts
new file mode 100644
index 0000000..5f07bef
--- /dev/null
+++ b/hooks/useDebounce.ts
@@ -0,0 +1,10 @@
+import { useState, useEffect } from 'react';
+
+export function useDebounce<T>(value: T, delay: number = 300): T {
+  const [debounced, setDebounced] = useState(value);
+  useEffect(() => {
+    const timer = setTimeout(() => setDebounced(value), delay);
+    return () => clearTimeout(timer);
+  }, [value, delay]);
+  return debounced;
+}
diff --git a/lib/audit.ts b/lib/audit.ts
new file mode 100644
index 0000000..3327122
--- /dev/null
+++ b/lib/audit.ts
@@ -0,0 +1,36 @@
+import { query } from './db';
+
+/**
+ * Log an audit event to the audit_events table.
+ *
+ * @param eventType   - e.g. 'petition.created', 'signature.added', 'login', 'campaign.sent'
+ * @param entityType  - e.g. 'petition', 'user', 'campaign', 'signature'
+ * @param entityId    - primary key of the affected entity (nullable)
+ * @param metadata    - arbitrary JSON payload with extra context
+ * @param ipAddress   - client IP if available
+ */
+export async function auditLog(
+  eventType: string,
+  entityType: string,
+  entityId: string | null,
+  metadata?: Record<string, unknown>,
+  ipAddress?: string,
+): Promise<void> {
+  const metaJson = metadata ? JSON.stringify(metadata) : null;
+
+  console.log(
+    `[audit] ${eventType} | ${entityType} | ${entityId ?? '(none)'} | ip=${ipAddress ?? 'unknown'}`,
+    metadata ? JSON.stringify(metadata) : '',
+  );
+
+  try {
+    await query(
+      `INSERT INTO audit_events (event_type, entity_type, entity_id, metadata, ip_address)
+       VALUES ($1, $2, $3, $4, $5)`,
+      [eventType, entityType, entityId, metaJson, ipAddress ?? null],
+    );
+  } catch (err) {
+    // Audit failures should never crash the calling operation
+    console.error('[audit] Failed to write audit event:', (err as Error).message);
+  }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..7191f61
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,10 @@
+import { createAuth } from '@dw/nextjs-admin-login';
+
+export const auth = createAuth({ cookieName: 'patty-auth' });
+export const { COOKIE_NAME: AUTH_COOKIE_NAME, createSession, verifyAuth,
+               buildAuthCookie, buildLogoutCookie, hashPassword,
+               // 2026-05-06 (tick 47): re-export v0.2.0 org-scoped helpers.
+               // Backward compatible — accepts both v1 (3-part) and v2
+               // (4-part) tokens. Adopt at consumer-route's discretion.
+               createSessionWithOrg, verifyAuthWithOrg } = auth;
+export type { AuthSession } from '@dw/nextjs-admin-login';
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..cd2939c
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,45 @@
+import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg';
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL,
+  max: 10,
+  idleTimeoutMillis: 30000,
+  connectionTimeoutMillis: 5000,
+});
+
+// Log pool errors so they don't crash the process
+pool.on('error', (err: Error) => {
+  console.error('[db] Unexpected pool error:', err.message);
+});
+
+/**
+ * Execute a parameterized query against the pool.
+ * Returns the full QueryResult so callers can read .rows, .rowCount, etc.
+ */
+export async function query<T extends QueryResultRow = QueryResultRow>(
+  text: string,
+  params?: unknown[],
+): Promise<QueryResult<T>> {
+  const start = Date.now();
+  try {
+    const result = await pool.query<T>(text, params);
+    const duration = Date.now() - start;
+    if (duration > 2000) {
+      console.warn(`[db] Slow query (${duration}ms):`, text.slice(0, 120));
+    }
+    return result;
+  } catch (err) {
+    console.error('[db] Query error:', (err as Error).message, '\n  SQL:', text.slice(0, 200));
+    throw err;
+  }
+}
+
+/**
+ * Acquire a dedicated client from the pool — use for transactions.
+ * IMPORTANT: Always call client.release() in a finally block.
+ */
+export async function getClient(): Promise<PoolClient> {
+  return pool.connect();
+}
+
+export default pool;
diff --git a/lib/gemini.ts b/lib/gemini.ts
new file mode 100644
index 0000000..3fe075f
--- /dev/null
+++ b/lib/gemini.ts
@@ -0,0 +1,111 @@
+/**
+ * Gemini 2.0 Flash wrapper — single source of truth for Patty's 7 AI call
+ * sites: petitions/generate, campaigns/generate, trending/discover,
+ * orbit/{rss,suggest-alliance,batch-link,generate}.
+ *
+ * 2026-05-05 (tick 27): ported from Grant's lib/gemini.ts after the
+ * architect-reviewer flagged 7x duplication of (key + URL + AbortSignal +
+ * fetch + parse + fence-strip) across the same routes that just got their
+ * hardcoded Gemini key scrubbed. This wrapper forces env discipline — if
+ * GEMINI_API_KEY isn't set, every callsite gets a 503 reason='no_key'
+ * instead of silently calling Google with key=''.
+ *
+ * Returns a Result-shaped object instead of throwing — callers map status
+ * codes (504 abort / 502 upstream / 500 parse). Generic <T> is the typed
+ * shape for parsed JSON when parseJson=true; raw string is also returned.
+ */
+
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const MODEL = 'gemini-2.0-flash';
+const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
+
+export type GeminiOk<T> = {
+  ok: true;
+  data: T;
+  raw: string;
+  inputTokens: number;
+  outputTokens: number;
+};
+
+export type GeminiErr = {
+  ok: false;
+  reason: 'no_key' | 'timeout' | 'upstream' | 'parse';
+  status: number;
+  detail?: string;
+};
+
+export type GeminiResult<T> = GeminiOk<T> | GeminiErr;
+
+export async function callGemini<T = unknown>(opts: {
+  prompt: string;
+  maxTokens?: number;
+  temperature?: number;
+  timeoutMs?: number;
+  parseJson?: boolean;
+}): Promise<GeminiResult<T>> {
+  if (!GEMINI_KEY) {
+    return { ok: false, reason: 'no_key', status: 503, detail: 'GEMINI_API_KEY env unset' };
+  }
+
+  const {
+    prompt,
+    maxTokens = 4096,
+    temperature = 0.7,
+    timeoutMs = 25_000,
+    parseJson = true,
+  } = opts;
+
+  let res: Response;
+  try {
+    res = await fetch(`${GEMINI_URL}?key=${GEMINI_KEY}`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        contents: [{ parts: [{ text: prompt }] }],
+        generationConfig: { temperature, maxOutputTokens: maxTokens },
+      }),
+      signal: AbortSignal.timeout(timeoutMs),
+    });
+  } catch (e) {
+    return { ok: false, reason: 'timeout', status: 504, detail: (e as Error).message };
+  }
+
+  if (!res.ok) {
+    const detail = await res.text().catch(() => '');
+    console.error('[lib/gemini] upstream error:', res.status, detail.slice(0, 500));
+    return { ok: false, reason: 'upstream', status: 502, detail: `gemini ${res.status}` };
+  }
+
+  const data = await res.json();
+  const raw: string = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
+  const usage = data.usageMetadata ?? {};
+  const inputTokens: number = usage.promptTokenCount ?? 0;
+  const outputTokens: number = usage.candidatesTokenCount ?? 0;
+
+  if (!parseJson) {
+    return {
+      ok: true,
+      data: raw as unknown as T,
+      raw,
+      inputTokens,
+      outputTokens,
+    };
+  }
+
+  // Strip ```json fences then JSON.parse. Caller is responsible for
+  // structural validation of the parsed payload.
+  const cleaned = raw.replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
+  try {
+    const parsed = JSON.parse(cleaned) as T;
+    return {
+      ok: true,
+      data: parsed,
+      raw,
+      inputTokens,
+      outputTokens,
+    };
+  } catch (e) {
+    console.error('[lib/gemini] parse error:', cleaned.slice(0, 500));
+    return { ok: false, reason: 'parse', status: 500, detail: (e as Error).message };
+  }
+}
diff --git a/lib/sanitize.ts b/lib/sanitize.ts
new file mode 100644
index 0000000..ab24261
--- /dev/null
+++ b/lib/sanitize.ts
@@ -0,0 +1,66 @@
+/**
+ * lib/sanitize.ts — petition body_html allowlist sanitizer.
+ *
+ * 2026-05-05 (architect-reviewer P1 + tick 27): replaces the handwritten
+ * regex-based sanitizeHtml() in app/petitions/[slug]/page.tsx (which only
+ * stripped <script>/<iframe>/on* and was bypassable via SVG, data: URIs,
+ * mathml, malformed nested tags, etc.).
+ *
+ * Sanitization happens at TWO boundaries:
+ *   1. POST/PATCH /api/petitions — sanitize before INSERT/UPDATE so the
+ *      database itself is the trust boundary
+ *   2. Render — sanitize again as defense-in-depth (cheap, idempotent)
+ *
+ * Why a SHARED module: keeps the allowlist in one place. Drift between
+ * "what we accept" and "what we render" is exactly the gap that lets
+ * stored XSS slip through.
+ */
+
+import sanitizeHtmlLib, { type IOptions } from 'sanitize-html';
+
+export const PETITION_HTML_OPTS: IOptions = {
+  // Tags suitable for petition body content. Includes basic typography,
+  // lists, headings, blockquote, links, and hr. NO <img> (would let users
+  // beacon out to attacker domains), NO <iframe>, NO <script>, NO <style>.
+  allowedTags: [
+    'p', 'br', 'hr',
+    'strong', 'em', 'b', 'i', 'u', 's', 'mark',
+    'ul', 'ol', 'li',
+    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+    'blockquote', 'pre', 'code',
+    'a',
+  ],
+  allowedAttributes: {
+    a: ['href', 'title', 'target', 'rel'],
+  },
+  allowedSchemes: ['http', 'https', 'mailto'],
+  allowedSchemesAppliedToAttributes: ['href'],
+  // Force every <a> to open externally with rel=noopener,noreferrer
+  transformTags: {
+    a: (tagName, attribs) => ({
+      tagName,
+      attribs: {
+        ...attribs,
+        target: '_blank',
+        rel: 'noopener noreferrer',
+      },
+    }),
+  },
+  disallowedTagsMode: 'discard',
+};
+
+export function sanitizePetitionBody(html: string): string {
+  if (typeof html !== 'string') return '';
+  return sanitizeHtmlLib(html, PETITION_HTML_OPTS);
+}
+
+/**
+ * Hard size cap for body_html — 200 KB is a generous limit for a petition
+ * (~50,000 words of plain text, far more for HTML overhead). Anything
+ * larger is suspicious (cost amplification, DoS via huge prompt).
+ */
+export const MAX_BODY_HTML_BYTES = 200_000;
+
+export function bodyHtmlTooLarge(html: unknown): boolean {
+  return typeof html !== 'string' || html.length > MAX_BODY_HTML_BYTES;
+}
diff --git a/lib/sister-auth.ts b/lib/sister-auth.ts
new file mode 100644
index 0000000..5ce6fa7
--- /dev/null
+++ b/lib/sister-auth.ts
@@ -0,0 +1,34 @@
+import { NextResponse } from 'next/server';
+
+// Sister-service Basic auth — reads from env (audit 2026-05-04: removed inline literal).
+// Falls back to AUTH_PASSWORD which Patty's own login already requires.
+// If neither password env is set, SISTER_AUTH is '' and SISTER_OK is false; route
+// handlers MUST gate on SISTER_OK and return sisterUnconfigured() instead of fetching.
+//
+// NOTE on `?? 'admin'` (audit P? 2026-05-04 lint sweep): the username default is
+// intentional and SAFE — 'admin' is not a secret, just the conventional username
+// across all DW Express services. The audit's anti-pattern rule (no literal
+// fallbacks) targets PASSWORDS and SECRETS specifically. Using `??` (not `||`)
+// so an explicit empty-string SISTER_AUTH_USERNAME is preserved (treated as
+// "unset" only when actually undefined/null).
+const SISTER_PASS = process.env.SISTER_AUTH_PASSWORD ?? process.env.AUTH_PASSWORD ?? '';
+const SISTER_USER = process.env.SISTER_AUTH_USERNAME ?? 'admin';
+
+export const SISTER_AUTH: string = SISTER_PASS
+  ? 'Basic ' + Buffer.from(`${SISTER_USER}:${SISTER_PASS}`).toString('base64')
+  : '';
+
+export const SISTER_OK: boolean = SISTER_AUTH !== '';
+
+export function sisterUnconfigured(): NextResponse {
+  return NextResponse.json(
+    { error: 'sister auth not configured (SISTER_AUTH_PASSWORD or AUTH_PASSWORD env required)' },
+    { status: 503 },
+  );
+}
+
+// Raw credentials for sister services that use form/JSON login (e.g. cookie auth)
+// rather than a Basic Authorization header.
+export function sisterCredentials(): { username: string; password: string } {
+  return { username: SISTER_USER, password: SISTER_PASS };
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..ea34d78
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,98 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { verifyAuth } from '@/lib/auth';
+
+/**
+ * Patty middleware — auth gate, request-id injection, rate-limit on AI routes.
+ *
+ * 2026-05-05 (architect-reviewer + tick 27): collapses 40 copies of
+ * `verifyAuth(request); if (!user) return 401;` across 23 routes into one
+ * place. Adds `x-request-id` for log correlation. Adds in-memory sliding-
+ * window rate-limit on the 7 Gemini routes (was 4 in Grant; Patty has
+ * more AI surface area).
+ *
+ * Key difference from Grant's middleware: Patty stays on the legacy
+ * `verifyAuth` (returns username string) — Patty's data model is single-
+ * tenant per-user (no organizations table), so the orgId fast-path Grant
+ * uses doesn't apply yet. Architect explicitly recommended SKIP for now.
+ *
+ * Runtime: Node (verifyAuth uses node:crypto). Next.js 16 supports
+ * Node runtime middleware natively.
+ */
+
+export const config = {
+  runtime: 'nodejs',
+  matcher: ['/api/:path*'],
+};
+
+// 2026-05-05 (P1-1): in-memory sliding-window rate-limit. Per-user-per-route
+// counters live in a Map; entries expire after WINDOW_MS. v1 implementation —
+// survives a single-process pm2 restart but not horizontal scale.
+type Bucket = { count: number; resetAt: number };
+const buckets: Map<string, Bucket> = new Map();
+const WINDOW_MS = 60_000;
+const AI_LIMIT_PER_MINUTE = 10;
+
+const AI_ROUTES = new Set([
+  '/api/petitions/generate',
+  '/api/campaigns/generate',
+  '/api/trending/discover',
+  '/api/orbit/rss',
+  '/api/orbit/suggest-alliance',
+  '/api/orbit/batch-link',
+  '/api/orbit/generate',
+]);
+function isAiRoute(pathname: string): boolean {
+  return AI_ROUTES.has(pathname);
+}
+
+// 2026-05-05 (architect P1): public-bypass set INCLUDES /api/public/signatures
+// — Patty's only unauthenticated write endpoint (signature submission from
+// petition pages). Don't gate it behind auth or no one can sign.
+const PUBLIC_API = new Set([
+  '/api/auth/login',
+  '/api/auth/logout',
+  '/api/auth/session',
+  '/api/public/signatures',
+  '/api/health',
+]);
+
+export function middleware(request: NextRequest) {
+  const { pathname } = request.nextUrl;
+
+  // Public endpoints bypass the gate.
+  if (PUBLIC_API.has(pathname)) {
+    return injectRequestId(NextResponse.next(), request);
+  }
+
+  // Auth gate for everything else under /api/**.
+  const username = verifyAuth(request);
+  if (!username) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+  }
+
+  // Rate-limit the Gemini-paid AI routes. Key by username (single-tenant).
+  if (isAiRoute(pathname)) {
+    const key = `${username}:${pathname}`;
+    const now = Date.now();
+    const b = buckets.get(key);
+    if (!b || b.resetAt < now) {
+      buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
+    } else if (b.count >= AI_LIMIT_PER_MINUTE) {
+      return NextResponse.json(
+        { error: 'rate_limited', retry_after_ms: b.resetAt - now },
+        { status: 429, headers: { 'retry-after': Math.ceil((b.resetAt - now) / 1000).toString() } },
+      );
+    } else {
+      b.count++;
+    }
+  }
+
+  return injectRequestId(NextResponse.next(), request);
+}
+
+function injectRequestId(res: NextResponse, request: NextRequest): NextResponse {
+  const incoming = request.headers.get('x-request-id');
+  const reqId = incoming || crypto.randomUUID();
+  res.headers.set('x-request-id', reqId);
+  return res;
+}
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..e9ffa30
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  /* config options here */
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a613e23
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1997 @@
+{
+  "name": "patty-petitions",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "patty-petitions",
+      "version": "0.1.0",
+      "dependencies": {
+        "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+        "@types/pg": "^8.16.0",
+        "@types/sanitize-html": "^2.16.1",
+        "lucide-react": "^0.575.0",
+        "next": "16.1.6",
+        "pg": "^8.19.0",
+        "react": "19.2.3",
+        "react-dom": "19.2.3",
+        "sanitize-html": "^2.17.3"
+      },
+      "devDependencies": {
+        "@tailwindcss/postcss": "^4",
+        "@types/node": "^20",
+        "@types/react": "^19",
+        "@types/react-dom": "^19",
+        "tailwindcss": "^4",
+        "typescript": "^5"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@dw/nextjs-admin-login": {
+      "version": "0.2.0",
+      "resolved": "file:../../../../tmp/dw-nextjs-admin-login-0.2.0.tgz",
+      "peerDependencies": {
+        "next": "^15.0.0 || ^16.0.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+      "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+      "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@next/env": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+      "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+      "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+      "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+      "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+      "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+      "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+      "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+      "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+      "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.15",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
+      "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "enhanced-resolve": "^5.19.0",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.31.1",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.2.1"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz",
+      "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.2.1",
+        "@tailwindcss/oxide-darwin-arm64": "4.2.1",
+        "@tailwindcss/oxide-darwin-x64": "4.2.1",
+        "@tailwindcss/oxide-freebsd-x64": "4.2.1",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.2.1",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.2.1",
+        "@tailwindcss/oxide-linux-x64-musl": "4.2.1",
+        "@tailwindcss/oxide-wasm32-wasi": "4.2.1",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.2.1"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz",
+      "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz",
+      "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz",
+      "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz",
+      "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz",
+      "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz",
+      "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz",
+      "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz",
+      "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz",
+      "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz",
+      "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.8.1",
+        "@emnapi/runtime": "^1.8.1",
+        "@emnapi/wasi-threads": "^1.1.0",
+        "@napi-rs/wasm-runtime": "^1.1.1",
+        "@tybys/wasm-util": "^0.10.1",
+        "tslib": "^2.8.1"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+      "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz",
+      "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz",
+      "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.2.1",
+        "@tailwindcss/oxide": "4.2.1",
+        "postcss": "^8.5.6",
+        "tailwindcss": "4.2.1"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.35",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
+      "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/pg": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
+      "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.14",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+      "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
+    "node_modules/@types/sanitize-html": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
+      "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
+      "license": "MIT",
+      "dependencies": {
+        "htmlparser2": "^10.1"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+      "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001774",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+      "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/deepmerge": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+      "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/dom-serializer/node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.19.0",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+      "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.3.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/htmlparser2": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+      "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "entities": "^7.0.1"
+      }
+    },
+    "node_modules/is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+      "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.31.1",
+        "lightningcss-darwin-arm64": "1.31.1",
+        "lightningcss-darwin-x64": "1.31.1",
+        "lightningcss-freebsd-x64": "1.31.1",
+        "lightningcss-linux-arm-gnueabihf": "1.31.1",
+        "lightningcss-linux-arm64-gnu": "1.31.1",
+        "lightningcss-linux-arm64-musl": "1.31.1",
+        "lightningcss-linux-x64-gnu": "1.31.1",
+        "lightningcss-linux-x64-musl": "1.31.1",
+        "lightningcss-win32-arm64-msvc": "1.31.1",
+        "lightningcss-win32-x64-msvc": "1.31.1"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+      "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+      "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+      "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+      "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+      "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+      "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+      "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+      "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+      "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+      "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.31.1",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+      "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lucide-react": {
+      "version": "0.575.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
+      "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/next": {
+      "version": "16.1.6",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+      "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "16.1.6",
+        "@swc/helpers": "0.5.15",
+        "baseline-browser-mapping": "^2.8.3",
+        "caniuse-lite": "^1.0.30001579",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.6"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": ">=20.9.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "16.1.6",
+        "@next/swc-darwin-x64": "16.1.6",
+        "@next/swc-linux-arm64-gnu": "16.1.6",
+        "@next/swc-linux-arm64-musl": "16.1.6",
+        "@next/swc-linux-x64-gnu": "16.1.6",
+        "@next/swc-linux-x64-musl": "16.1.6",
+        "@next/swc-win32-arm64-msvc": "16.1.6",
+        "@next/swc-win32-x64-msvc": "16.1.6",
+        "sharp": "^0.34.4"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.51.1",
+        "babel-plugin-react-compiler": "*",
+        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/next/node_modules/postcss": {
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/parse-srcset": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+      "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
+      "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.11.0",
+        "pg-pool": "^3.12.0",
+        "pg-protocol": "^1.12.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
+      "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz",
+      "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz",
+      "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
+      "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.3"
+      }
+    },
+    "node_modules/sanitize-html": {
+      "version": "2.17.3",
+      "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
+      "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
+      "license": "MIT",
+      "dependencies": {
+        "deepmerge": "^4.2.2",
+        "escape-string-regexp": "^4.0.0",
+        "htmlparser2": "^10.1.0",
+        "is-plain-object": "^5.0.0",
+        "parse-srcset": "^1.0.2",
+        "postcss": "^8.3.11"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "optional": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/styled-jsx": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
+      "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+      "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "license": "MIT"
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3286af6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "patty-petitions",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start"
+  },
+  "dependencies": {
+    "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+    "@types/pg": "^8.16.0",
+    "@types/sanitize-html": "^2.16.1",
+    "lucide-react": "^0.575.0",
+    "next": "16.1.6",
+    "pg": "^8.19.0",
+    "react": "19.2.3",
+    "react-dom": "19.2.3",
+    "sanitize-html": "^2.17.3"
+  },
+  "devDependencies": {
+    "@tailwindcss/postcss": "^4",
+    "@types/node": "^20",
+    "@types/react": "^19",
+    "@types/react-dom": "^19",
+    "tailwindcss": "^4",
+    "typescript": "^5"
+  }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..61e3684
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..bf1b38a
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#7c3aed"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">📢</text>
+</svg>
\ No newline at end of file
diff --git a/public/file.svg b/public/file.svg
new file mode 100644
index 0000000..004145c
--- /dev/null
+++ b/public/file.svg
@@ -0,0 +1 @@
+<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
new file mode 100644
index 0000000..567f17b
--- /dev/null
+++ b/public/globe.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
new file mode 100644
index 0000000..5174b28
--- /dev/null
+++ b/public/next.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
new file mode 100644
index 0000000..7705396
--- /dev/null
+++ b/public/vercel.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
new file mode 100644
index 0000000..b2b2a44
--- /dev/null
+++ b/public/window.svg
@@ -0,0 +1 @@
+<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3a13f90
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,34 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "react-jsx",
+    "incremental": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts",
+    ".next/dev/types/**/*.ts",
+    "**/*.mts"
+  ],
+  "exclude": ["node_modules"]
+}

(oldest)  ·  back to Patty  ·  harden(auth): add scrypt + rate-limit + refuse-to-boot to /a 6065515 →