[object Object]

← back to Letsbegin

initial scaffold (gitify-all 2026-05-06)

5987deca0bb62b12a26656c18cf0c2aba503fd8c · 2026-05-06 10:25:05 -0700 · Steve Abrams

Files touched

Diff

commit 5987deca0bb62b12a26656c18cf0c2aba503fd8c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:25:05 2026 -0700

    initial scaffold (gitify-all 2026-05-06)
---
 .gitignore                             |   44 +
 CHANGES.md                             |   22 +
 CHANGES2.md                            |   12 +
 CLAUDE.md                              |  101 ++
 DEBUG_SUMMARY.md                       |  122 ++
 add_ai_desc_thibaut.js                 |  109 ++
 ani_full_monty.js                      |  323 +++++
 app/api/auth/login/route.ts            |   75 +
 app/api/auth/logout/route.ts           |   18 +
 app/api/auth/session/route.ts          |   50 +
 app/api/claude/suggest/route.ts        |  185 +++
 app/api/directory/route.ts             |   85 ++
 app/api/files/route.ts                 |   28 +
 app/api/live/check/route.ts            |  178 +++
 app/api/live/route.ts                  |  241 ++++
 app/api/prd/clarify/route.ts           |  149 ++
 app/api/prd/generate/route.ts          |  163 +++
 app/api/processes/analyze/route.ts     |  144 ++
 app/api/processes/details/route.ts     |  193 +++
 app/api/processes/fix/route.ts         |  285 ++++
 app/api/processes/route.ts             |  277 ++++
 app/api/processes/start/route.ts       |  221 +++
 app/api/processes/stop/route.ts        |   71 +
 app/api/projects/route.ts              |   33 +
 app/api/projects/scan/route.ts         |  404 ++++++
 app/api/quickmode/route.ts             |   67 +
 app/api/ralph/continue/route.ts        |  153 +++
 app/api/ralph/control/route.ts         |  194 +++
 app/api/ralph/convert/route.ts         |  241 ++++
 app/api/ralph/run/route.ts             |  308 +++++
 app/api/ralph/status/route.ts          |  185 +++
 app/api/ralph/stop/route.ts            |   83 ++
 app/api/tracking/route.ts              |   38 +
 app/api/upload/route.ts                |   68 +
 app/api/url-info/route.ts              |  121 ++
 app/globals.css                        |  470 +++++++
 app/icon.svg                           |    4 +
 app/layout.tsx                         |   22 +
 app/live/page.tsx                      |  944 +++++++++++++
 app/login/page.tsx                     |  146 ++
 app/page.tsx                           |  409 ++++++
 components/AuthProvider.tsx            |  100 ++
 components/CollapsibleSection.tsx      |   39 +
 components/DirectoryTree.tsx           |  120 ++
 components/PRDGeneratorStep.tsx        | 1283 ++++++++++++++++++
 components/ProcessStatusBar.tsx        |  788 +++++++++++
 components/ProjectSelector.tsx         |  123 ++
 components/QuickModeStep.tsx           |  441 ++++++
 components/RalphConverterStep.tsx      |  472 +++++++
 components/RalphyBoyStep.tsx           |  476 +++++++
 components/RecentItemsFooter.tsx       |  278 ++++
 components/SimplifiedQuestionnaire.tsx |  628 +++++++++
 components/StepTabs.tsx                |   70 +
 components/ui/SidebarDemo.tsx          |  221 +++
 components/ui/sidebar.tsx              |  188 +++
 croppy_glm.js                          |  755 +++++++++++
 data/tracking.json                     |  289 ++++
 ecosystem.config.js                    |   17 +
 fix_dwpx_images.js                     |  114 ++
 fix_rew_products.js                    |  218 +++
 fix_x_mfr.js                           |   74 +
 glitter_full_monty.js                  |  618 +++++++++
 lib/auth.ts                            |   37 +
 lib/gemini-image.ts                    |  157 +++
 lib/git.ts                             |  122 ++
 lib/llm-local.ts                       |  181 +++
 lib/sheets.ts                          |  117 ++
 lib/utils.ts                           |    6 +
 middleware.ts                          |  100 ++
 migrate_global_to_custom.js            |  207 +++
 next.config.ts                         |   17 +
 nina_crop.js                           |  447 ++++++
 nina_full_monty.js                     |  754 +++++++++++
 nina_names_colors.js                   |  158 +++
 package-lock.json                      | 2326 ++++++++++++++++++++++++++++++++
 package.json                           |   33 +
 postcss.config.mjs                     |    7 +
 retro_full_monty.js                    |  617 +++++++++
 scrape_dg_specs.js                     |  242 ++++
 scripts/ralph-llm-runner.js            |  286 ++++
 sequin_colors_titles.js                |  107 ++
 sequin_full_monty.js                   |  201 +++
 sequin_monty_noimages.js               |  152 +++
 sync_brands_page.js                    |  458 +++++++
 test_brands.js                         |  450 ++++++
 tsconfig.json                          |   41 +
 86 files changed, 21521 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e234a0e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,44 @@
+# Dependencies
+node_modules/
+.pnp/
+.pnp.js
+
+# Build
+.next/
+out/
+build/
+dist/
+
+# Environment
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# Logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# TypeScript
+*.tsbuildinfo
+next-env.d.ts
+
+# Testing
+coverage/
+.env.*.local
+.env.*
+tmp/
+*.log
+*.bak
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..c6673a0
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,22 @@
+## Applied
+- `.env.example:1` — added placeholder-only env keys for Gemini, auth, admin scan, database, Ralph, and Shopify — documents required configuration without real secrets.
+- `lib/auth.ts:8` — replaced hardcoded auth/session fallbacks with required env lookup — removes fallback credentials from source.
+- `lib/gemini-image.ts:7`, `app/api/prd/generate/route.ts:8`, `app/api/prd/clarify/route.ts:4`, `app/api/ralph/convert/route.ts:6`, `app/api/claude/suggest/route.ts:6`, `app/api/url-info/route.ts:4`, `app/api/processes/analyze/route.ts:6`, `app/api/processes/fix/route.ts:7` — replaced Gemini key fallbacks with fail-fast env checks — removes live API key from app source.
+- `scripts/ralph-llm-runner.js:45` — removed Gemini fallback key and fails fast if no Ralph/Gemini env key exists — removes live API key from runner.
+- `app/api/quickmode/route.ts:4` — replaced hardcoded Postgres URL with required `DATABASE_URL` — removes DB password from API source.
+- `app/api/projects/scan/route.ts:6` — replaced admin-password fallback with required `ADMIN_PASSWORD` — removes admin password from source.
+- `components/PRDGeneratorStep.tsx:339` — removed hardcoded scan admin header from client fetch — stops exposing the admin key in browser code.
+- `CLAUDE.md:6` — replaced real auth/API examples with env-based examples — removes documented secrets.
+- `app/api/prd/generate/route.ts:73`, `app/api/url-info/route.ts:31`, `app/api/quickmode/route.ts:32` — added invalid-JSON guards returning 400 — avoids malformed bodies falling through to 500.
+- `sync_brands_page.js:16`, `test_brands.js:16`, `fix_rew_products.js:7`, `nina_full_monty.js:24`, `add_ai_desc_thibaut.js:5`, `ani_full_monty.js:17`, `croppy_glm.js:27`, `fix_dwpx_images.js:7`, `fix_x_mfr.js:3`, `glitter_full_monty.js:21`, `nina_crop.js:19`, `nina_names_colors.js:5`, `retro_full_monty.js:24`, `sequin_colors_titles.js:6`, `sequin_full_monty.js:15`, `sequin_monty_noimages.js:6` — replaced script API tokens/keys/DB URLs with env variables and fail-fast checks — removes live secrets from non-migration scripts.
+
+## Deferred (needs Steve)
+- P0 — `.env.local:1` — live secrets may remain in local env — left alone per instruction; rotate/purge history needs Steve.
+- P0 — `scrape_dg_specs.js:12`, `migrate_global_to_custom.js:29` — remaining hardcoded DB/Shopify secrets — deferred because these are DDL/migration-adjacent scripts.
+- P0 — `middleware.ts:6` — admin/process/API prefixes are public — auth/CSRF redesign is beyond safe fixes.
+- P0 — `lib/auth.ts:8`, `app/api/auth/login/route.ts:58`, `middleware.ts:70` — sessions are unsigned and cookie `secure` is false — needs session design change.
+- P0 — `app/api/ralph/run/route.ts:57`, `app/api/processes/start/route.ts:83`, `app/api/processes/stop/route.ts:17`, `app/api/live/check/route.ts:123` — arbitrary project/process control and shell execution risks — needs sandboxing/authorization.
+- P0 — `app/api/url-info/route.ts:29` — public SSRF remains — needs URL/IP validation and rate limiting.
+- P1 — `app/api/files/route.ts:14`, `app/api/directory/route.ts:71`, `app/api/upload/route.ts:16` — broad path access/upload/delete guards — needs realpath allowlist design.
+- P1 — `app/api/projects/scan/route.ts:12`, `app/api/projects/scan/route.ts:215` — referer trust and shell interpolation remain — needs real authorization plus exec/native API rewrite.
+- P1 — `lib/sheets.ts:32`, `app/api/ralph/status/route.ts:41` — concurrent JSON state writes/GET mutation — needs locking or durable state store.
diff --git a/CHANGES2.md b/CHANGES2.md
new file mode 100644
index 0000000..97b40f0
--- /dev/null
+++ b/CHANGES2.md
@@ -0,0 +1,12 @@
+## Round 2 Applied
+- `scripts/ralph-llm-runner.js:45` — removed the module-scope Gemini env failure and kept the key lookup passive — fixes CODEX_REREVIEW "STATUS broken" / P1 new issue where default Ollama runs required Gemini credentials.
+- `scripts/ralph-llm-runner.js:110` — added the Gemini env failure inside `callGemini()` only — follows CODEX_REREVIEW recommendation to require `GEMINI_API_KEY_RALPH`/`GEMINI_API_KEY` only when the Gemini backend is actually used.
+- `app/api/prd/clarify/route.ts:75` — wrapped `request.json()` with a local 400 invalid-JSON response — fixes CODEX_REREVIEW missed call-site at line 75.
+- `app/api/ralph/convert/route.ts:141` — wrapped `request.json()` with a local 400 invalid-JSON response — fixes CODEX_REREVIEW missed call-site at line 141.
+- `app/api/claude/suggest/route.ts:130` — wrapped `request.json()` with a local 400 invalid-JSON response and kept safe defaults for destructured fields — fixes CODEX_REREVIEW missed call-site at line 130.
+- `app/api/processes/analyze/route.ts:33` — wrapped `request.json()` with a local 400 invalid-JSON response — fixes CODEX_REREVIEW missed call-site at line 33.
+- `app/api/processes/fix/route.ts:36` — wrapped `request.json()` with a local 400 invalid-JSON response — fixes CODEX_REREVIEW missed call-site at line 36.
+
+## Still Deferred
+- Original `CHANGES.md` deferred security items remain for Steve: local secret rotation/history purge, auth/session redesign, process/shell sandboxing, SSRF controls, broad file path/upload guards, referer trust/shell interpolation, and concurrent state-write design.
+- No additional safe P0/P1/BROKEN/concern items from `CODEX_REREVIEW.md` were left unapplied.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3d8c3ca
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,101 @@
+# Letsbegin - PRD & Ralph Automation UI
+
+## Quick Reference
+
+- **URL**: http://45.61.58.125:7300
+- **Auth**: configured by `AUTH_PASSWORD`
+- **PM2**: letsbegin
+- **Port**: 7300
+- **Skill**: ~/.claude/skills/letsbegin/SKILL.md
+
+## Tech Stack
+
+- Next.js 16 + React 19
+- TailwindCSS 4
+- PostgreSQL (via pg)
+- **Gemini 2.0 Flash API** (FREE - no credits needed!)
+
+## AI Configuration
+
+**ALL AI features use Gemini 2.0 Flash - NO Anthropic API required!**
+
+(Updated 2026-04-30) Ralph's previous `claude --dangerously-skip-permissions` subprocess was replaced with `scripts/ralph-llm-runner.js`, a local-LLM story executor. **Default backend: Ollama qwen3:14b on `http://localhost:11434`** (free, private, no quota). Set `RALPH_LLM=gemini` to fall back to Gemini 2.0 Flash. Per-story flow: model receives the story + project context (tree, package.json, tsconfig, CLAUDE.md) and returns strict JSON `{files: [{path, content}], shell: [], done, summary}`. The runner strips qwen3 `<think>` blocks + JSON fences, writes each file, runs typecheck (`./node_modules/.bin/tsc --noEmit` if present, otherwise skipped honestly), and exits 0/1. Honest limit: both backends are single-shot, not agentic — stories needing iterative read-edit-test loops will fail more often than under Claude. Best for "implement this single file" stories. Smoke-tested 2026-04-30 with `RALPH_SMOKE_OK` marker — Ollama qwen3:14b succeeded.
+
+```typescript
+// Text generation
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+// Image analysis (lib/gemini-image.ts)
+import { analyzeImage, analyzeWallcovering } from '@/lib/gemini-image'
+```
+
+**Routes using Gemini:**
+- `/api/prd/clarify` - Clarifying questions
+- `/api/prd/generate` - PRD generation
+- `/api/ralph/convert` - User story conversion
+- `/api/claude/suggest` - Form suggestions
+- `/api/url-info` - URL analysis
+- `/api/processes/analyze` - Error analysis
+- `/api/processes/fix` - Auto-fix generation
+
+## Key Features
+
+1. **Quick Mode** - Fast PRD generation from plain text
+2. **PRD Generator** - Full form-based PRD creation
+3. **Ralph Converter** - Convert PRDs to user stories
+4. **Ralphy Boy Runner** - Execute tasks with AI
+
+## API Routes
+
+```
+/api/ralph/run      - Start Ralph (fresh)
+/api/ralph/continue - Continue Ralph (pending only) ← USE THIS
+/api/ralph/stop     - Stop Ralph
+/api/ralph/status   - Get status
+/api/ralph/convert  - Convert PRD to tasks
+/api/projects       - List projects
+/api/prd            - Generate PRD
+/api/quickmode      - Quick mode PRD
+/api/processes      - PM2 processes
+/api/live           - Live monitoring
+```
+
+## Critical Rules
+
+1. **ALWAYS use CONTINUE, not RUN** when resuming Ralph
+2. Never restart from scratch unless explicitly requested
+3. Check `/api/ralph/status` before starting
+4. Monitor progress at `/live`
+
+## Development
+
+```bash
+# Dev mode
+cd /root/Projects/Letsbegin
+npm run dev
+
+# Production
+pm2 restart letsbegin
+
+# Logs
+pm2 logs letsbegin --lines 50
+```
+
+## File Structure
+
+```
+app/
+├── api/ralph/     # Ralph control APIs
+├── api/prd/       # PRD generation
+├── api/projects/  # Project listing
+├── live/          # Live monitoring page
+└── page.tsx       # Main UI
+
+components/
+├── QuickModeStep.tsx
+├── PRDGeneratorStep.tsx
+├── RalphConverterStep.tsx
+├── RalphyBoyStep.tsx
+└── ProcessStatusBar.tsx
+```
diff --git a/DEBUG_SUMMARY.md b/DEBUG_SUMMARY.md
new file mode 100644
index 0000000..29f4dcc
--- /dev/null
+++ b/DEBUG_SUMMARY.md
@@ -0,0 +1,122 @@
+# GoodQuestionRalph Debugging Summary
+
+## Issues Fixed ✅
+
+### 1. Next.js Workspace Root Warning
+**Problem**: Next.js was detecting multiple lockfiles and inferring incorrect workspace root
+**Solution**: Added `outputFileTracingRoot` to `next.config.ts` to explicitly set the workspace root
+**File**: `next.config.ts`
+**Status**: ✅ Fixed
+
+### 2. Git Ownership Warning
+**Problem**: Git was complaining about dubious ownership in `/root/Projects/watches`
+**Solution**: Added the directory to git's safe.directory list
+**Command**: `git config --global --add safe.directory /root/Projects/watches`
+**Status**: ✅ Fixed
+
+### 3. TypeScript Compilation
+**Status**: ✅ No TypeScript errors found
+
+## Current Application Status
+
+### Service Status
+- **PM2 Process**: `letsbegin` (ID: 43) - ✅ Online
+- **Port**: 7300 - ✅ Listening
+- **URL**: http://45.61.58.125:7300
+- **Status**: Running and ready
+
+### API Endpoints Status
+All API routes are properly configured:
+- `/api/ralph/status` - ✅ Working
+- `/api/ralph/run` - ✅ Working
+- `/api/ralph/control` - ✅ Working
+- `/api/ralph/convert` - ✅ Working
+- `/api/ralph/stop` - ✅ Working
+
+## Potential Issues to Monitor
+
+### 1. Ralph State Management
+**Location**: `/tmp/ralph-state.json`
+**Current State**: 
+- Last run completed for `wine-finder` project
+- All 12 user stories are marked as "PENDING"
+- State shows `completed: true` but stories haven't been processed
+
+**Recommendation**: Check if Ralph process is actually executing stories or if it's completing immediately without work.
+
+### 2. Ralph Output Log
+**Location**: `/tmp/ralph-output.log`
+**Current Content**: Shows Ralph starting and immediately completing without processing stories
+**Issue**: The script appears to be running but not executing the story implementation loop
+
+**Potential Causes**:
+- Claude CLI might not be available for the `ralph` user
+- Permissions issues with project directories
+- Script execution errors not being captured
+
+### 3. Process ID Tracking
+**Issue**: State shows `pid: 0` which suggests the process might not be tracked correctly
+**Location**: `app/api/ralph/run/route.ts` line 220
+
+## Debugging Recommendations
+
+### 1. Check Ralph User Setup
+```bash
+# Verify ralph user exists and has proper permissions
+id ralph
+ls -la /home/ralph/.nvm/versions/node/v22.19.0/bin/claude
+```
+
+### 2. Test Ralph Script Execution
+```bash
+# Manually test the Ralph runner script
+sudo -u ralph bash /tmp/ralph-runner.sh
+```
+
+### 3. Check Claude CLI Availability
+```bash
+# Verify Claude CLI is accessible
+sudo -u ralph /home/ralph/.nvm/versions/node/v22.19.0/bin/claude --version
+```
+
+### 4. Monitor Real-time Execution
+```bash
+# Watch the output log in real-time
+tail -f /tmp/ralph-output.log
+```
+
+### 5. Check Project Permissions
+```bash
+# Verify ralph user can write to project directories
+sudo -u ralph touch /root/Projects/wine-finder/test.txt
+```
+
+## Code Review Notes
+
+### API Routes
+- All routes have proper error handling with `console.error`
+- State management uses `/tmp/ralph-state.json` for persistence
+- Process spawning uses `sudo -u ralph` for non-root execution
+
+### Components
+- `RalphyBoyStep.tsx` has proper polling mechanism for status updates
+- Error handling is implemented in the frontend
+- Log display is working correctly
+
+## Next Steps
+
+1. **Verify Ralph User Environment**: Ensure Claude CLI is properly installed and accessible
+2. **Test Manual Execution**: Run a Ralph script manually to identify issues
+3. **Add Better Logging**: Enhance error logging in the Ralph runner script
+4. **Monitor Execution**: Set up real-time monitoring for Ralph processes
+5. **Fix Process Tracking**: Ensure PID tracking works correctly
+
+## Files Modified
+- `next.config.ts` - Added `outputFileTracingRoot`
+- Git config - Added safe directory for watches project
+
+## Files to Review
+- `app/api/ralph/run/route.ts` - Main execution logic
+- `app/api/ralph/status/route.ts` - Status polling
+- `components/RalphyBoyStep.tsx` - Frontend integration
+- `/tmp/ralph-runner.sh` - Generated script (check if it's being created correctly)
diff --git a/add_ai_desc_thibaut.js b/add_ai_desc_thibaut.js
new file mode 100644
index 0000000..1ffd7b4
--- /dev/null
+++ b/add_ai_desc_thibaut.js
@@ -0,0 +1,109 @@
+const https = require('https');
+const fs = require('fs');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+const aiData = JSON.parse(fs.readFileSync('/tmp/thibaut_ai_desc.json', 'utf8'));
+const aiByMfr = {};
+for (const d of aiData) {
+  if (d.mfr_sku && d.ai_description) aiByMfr[d.mfr_sku.trim()] = d.ai_description.trim();
+}
+console.log(`Loaded ${Object.keys(aiByMfr).length} AI descriptions\n`);
+
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function buildTwoParaBody(existingBody, aiDesc, title) {
+  // Extract first meaningful paragraph text from existing body
+  let firstPara = '';
+  const pMatch = (existingBody || '').match(/<p[^>]*>(.*?)<\/p>/s);
+  if (pMatch) {
+    firstPara = pMatch[1].replace(/<[^>]+>/g, '').trim();
+  } else {
+    firstPara = (existingBody || '').replace(/<[^>]+>/g, '').trim();
+  }
+
+  // If first paragraph is empty/junk, generate a clean one
+  if (!firstPara || firstPara.length < 20 || /^specifications$/i.test(firstPara)) {
+    const cleanTitle = (title || '').split('|')[0].trim();
+    firstPara = `${cleanTitle} is a premium wallcovering by Thibaut. This design brings refined texture and sophistication to both residential and commercial interiors.`;
+  }
+
+  // Clean AI desc
+  let aiClean = aiDesc.charAt(0).toUpperCase() + aiDesc.slice(1);
+  aiClean = aiClean.replace(/\bwallpaper\b/gi, 'wallcovering');
+
+  // EXACTLY 2 paragraphs
+  return `<p>${firstPara}</p>\n<p>${aiClean}</p>`;
+}
+
+async function main() {
+  console.log('=== Thibaut Body Rewriter: Exactly 2 Paragraphs ===\n');
+  let cursor = null, page = 1, updated = 0, noMatch = 0, total = 0, errors = 0;
+
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({
+      query: `{ products(first: 25, query: "vendor:Thibaut AND status:active AND product_type:Wallcovering"${after}) {
+        edges { cursor node { id title bodyHtml metafields(first: 2, keys: ["custom.manufacturer_sku"]) { edges { node { value } } } } }
+        pageInfo { hasNextPage }
+      } }`
+    });
+
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+
+    const mutations = [];
+
+    for (const edge of edges) {
+      const p = edge.node;
+      cursor = edge.cursor;
+      total++;
+
+      const mfrSku = p.metafields?.edges?.[0]?.node?.value || '';
+      const aiDesc = aiByMfr[mfrSku];
+      if (!aiDesc) { noMatch++; continue; }
+
+      const newBody = buildTwoParaBody(p.bodyHtml, aiDesc, p.title);
+      mutations.push({ id: p.id, body: newBody });
+    }
+
+    for (const m of mutations) {
+      try {
+        const result = await gql({
+          query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+          variables: { i: { id: m.id, bodyHtml: m.body } }
+        });
+        const errs = result?.data?.productUpdate?.userErrors || [];
+        if (errs.length === 0) updated++;
+        else { errors++; if (errors <= 3) console.log(`  ERR: ${errs[0].message}`); }
+      } catch { errors++; }
+      await sleep(500);
+    }
+
+    if (page % 10 === 0 || !r?.data?.products?.pageInfo?.hasNextPage) {
+      console.log(`Page ${page}: total=${total} updated=${updated} noMatch=${noMatch} errors=${errors}`);
+    }
+
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    page++; await sleep(200);
+  }
+
+  console.log(`\n=== DONE === Updated: ${updated} | No AI match: ${noMatch} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/ani_full_monty.js b/ani_full_monty.js
new file mode 100644
index 0000000..c8db7cb
--- /dev/null
+++ b/ani_full_monty.js
@@ -0,0 +1,323 @@
+#!/usr/bin/env node
+/**
+ * ANI- Series Full Monty
+ * 1. Migrate global.* → custom.* specs
+ * 2. AI color analysis via Gemini
+ * 3. Update body to 2 paragraphs (editorial + AI)
+ * 4. Generate 2 residential room settings per product (bedroom + living room)
+ * 5. Upload room settings to Shopify
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function downloadImage(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close(); return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      res.pipe(file);
+      file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', e => { file.close(); reject(e); });
+  });
+}
+
+async function geminiAnalyze(imgPath, prompt) {
+  const imgB64 = fs.readFileSync(imgPath).toString('base64');
+  const req = {
+    contents: [{ parts: [
+      { text: prompt },
+      { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+    ]}],
+    generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+  };
+  fs.writeFileSync('/tmp/ani_gemini_req.json', JSON.stringify(req));
+
+  const result = execFileSync('curl', ['-s',
+    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`,
+    '-H', 'Content-Type: application/json',
+    '-d', `@/tmp/ani_gemini_req.json`
+  ], { timeout: 30000 }).toString();
+
+  const parsed = JSON.parse(result);
+  return parsed?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+}
+
+function geminiGenerateImage(imgPath, roomPrompt) {
+  return new Promise((resolve, reject) => {
+    const imgB64 = fs.readFileSync(imgPath).toString('base64');
+    const reqBody = JSON.stringify({
+      contents: [{ parts: [
+        { text: roomPrompt },
+        { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+      ]}],
+      generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
+    });
+
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({
+      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(reqBody) }
+    }, res => {
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => {
+        try {
+          const parsed = JSON.parse(Buffer.concat(chunks).toString());
+          for (const part of parsed?.candidates?.[0]?.content?.parts || []) {
+            if (part.inlineData) {
+              return resolve(Buffer.from(part.inlineData.data, 'base64'));
+            }
+          }
+          resolve(null);
+        } catch (e) { resolve(null); }
+      });
+    });
+    req.on('error', () => resolve(null));
+    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
+    req.write(reqBody);
+    req.end();
+  });
+}
+
+async function uploadImageToShopify(productId, imgBuffer, alt) {
+  const tmpFile = '/tmp/ani_room_upload.png';
+  fs.writeFileSync(tmpFile, imgBuffer);
+  const fileSize = fs.statSync(tmpFile).size;
+
+  const stage = await gql({
+    query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "room-setting.png", mimeType: "image/png", httpMethod: POST, fileSize: "${fileSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } userErrors { message } } }`
+  });
+
+  const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+  if (!target) return null;
+
+  const curlArgs = ['-s', '-X', 'POST', target.url];
+  for (const p of target.parameters) curlArgs.push('-F', `${p.name}=${p.value}`);
+  curlArgs.push('-F', `file=@${tmpFile}`);
+  execFileSync('curl', curlArgs, { timeout: 30000 });
+
+  const pid = productId.includes('/') ? productId : `gid://shopify/Product/${productId}`;
+  const mediaResult = await gql({
+    query: `mutation { productCreateMedia(productId: "${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${alt}"}]) { media { id } mediaUserErrors { message } } }`
+  });
+
+  return mediaResult?.data?.productCreateMedia?.media?.[0]?.id || null;
+}
+
+function cleanWidth(v) { const m = (v||'').match(/([\d.]+)/); return m ? `${m[1]} Inches` : null; }
+function cleanRepeat(v) {
+  if (!v || v === 'N/A') return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+function cleanFire(v) {
+  if (!v) return null;
+  let f = v.replace(/["']/g, '').trim();
+  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
+  return f;
+}
+function cleanMaterial(v) { return v ? v.replace(/wallpaper/gi, 'Wallcovering') : null; }
+function toTitleCase(s) {
+  if (!s) return '';
+  return s.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.substr(1).toLowerCase());
+}
+
+async function main() {
+  console.log('=== ANI- Series Full Monty ===\n');
+
+  // Fetch all 16 ANI products with ALL metafields
+  const r = await gql({
+    query: `{ products(first: 20, query: "sku:ANI-* AND status:active") {
+      edges { node { id title bodyHtml vendor images(first:1) { edges { node { url width height } } }
+        metafields(first: 30) { edges { node { namespace key value } } }
+      } }
+    } }`
+  });
+
+  const products = r?.data?.products?.edges?.map(e => e.node) || [];
+  console.log(`Found ${products.length} ANI products\n`);
+
+  let specsOk = 0, colorsOk = 0, bodiesOk = 0, roomsOk = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const pid = p.id;
+    console.log(`\n[${i+1}/${products.length}] ${p.title}`);
+
+    // Collect existing metafields
+    const globals = {};
+    const customs = {};
+    for (const m of p.metafields.edges) {
+      if (m.node.namespace === 'global') globals[m.node.key] = m.node.value;
+      if (m.node.namespace === 'custom') customs[m.node.key] = m.node.value;
+    }
+
+    // === 1. SPECS: Migrate global → custom ===
+    const mf = [];
+    const add = (k, v, t = 'single_line_text_field') => {
+      if (v && v.toString().trim() && !customs[k]) {
+        mf.push({ ownerId: pid, namespace: 'custom', key: k, value: v.toString().trim(), type: t });
+      }
+    };
+
+    add('width', cleanWidth(globals['width']));
+    add('pattern_repeat', cleanRepeat(globals['repeat']));
+    add('fire_rating', cleanFire(globals['fire_rating']));
+    add('material', cleanMaterial(globals['Construction'] || globals['Contents']), 'multi_line_text_field');
+    add('collection_name', globals['Collection']);
+    add('match_type', globals['MATCH'] || globals['Match']);
+    add('brand', globals['Brand'] || p.vendor);
+    add('packaging', globals['packaged']);
+    add('unit_of_measure', globals['unit_of_measure']);
+    add('origin', globals['Country'] || globals['Country-of-Origin']);
+    add('pattern_name', customs['pattern_name'] || p.title);
+    add('color', globals['color'] || globals['Color-Way']);
+
+    if (mf.length > 0) {
+      try {
+        await gql({ query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }', variables: { m: mf } });
+        specsOk++;
+        console.log(`  Specs: ${mf.length} fields set`);
+      } catch { errors++; }
+    } else {
+      console.log('  Specs: already complete');
+      specsOk++;
+    }
+
+    // === 2. AI COLOR ANALYSIS ===
+    const imgUrl = p.images?.edges?.[0]?.node?.url;
+    let aiDesc = '';
+    if (imgUrl && !customs['color_details']) {
+      try {
+        const tmpImg = '/tmp/ani_img.jpg';
+        await downloadImage(imgUrl, tmpImg);
+
+        const colorJson = await geminiAnalyze(tmpImg,
+          'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Title Case names.'
+        );
+
+        const colorText = colorJson.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
+        const colorData = JSON.parse(colorText);
+
+        // Also get AI description
+        aiDesc = await geminiAnalyze(tmpImg,
+          `Describe this wallcovering called "${p.title}" in 2 sentences for a commercial/architectural buyer. Mention the pattern, colors, and recommended use. Do NOT use the word "wallpaper" — use "wallcovering".`
+        );
+
+        // Set color metafields
+        const colorMf = [
+          { ownerId: pid, namespace: 'custom', key: 'color_hex', value: colorData.dominantHex, type: 'single_line_text_field' },
+          { ownerId: pid, namespace: 'custom', key: 'background_color', value: colorData.backgroundColor, type: 'single_line_text_field' },
+          { ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorData.colors), type: 'json' },
+        ];
+        await gql({ query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }', variables: { m: colorMf } });
+        colorsOk++;
+        console.log(`  Colors: ${colorData.dominantColor} (${colorData.dominantHex})`);
+      } catch (e) {
+        console.log(`  Colors: ERROR ${e.message?.slice(0,50)}`);
+        errors++;
+      }
+    } else {
+      console.log('  Colors: already done or no image');
+      colorsOk++;
+    }
+
+    // === 3. BODY: 2 paragraphs ===
+    const existingBody = (p.bodyHtml || '').replace(/<[^>]+>/g, '').trim();
+    let firstPara = existingBody.length > 20 ? existingBody : `${p.title} is a charming wallcovering by ${p.vendor}. This design brings whimsical character to any interior space.`;
+    let secondPara = aiDesc ? aiDesc.replace(/wallpaper/gi, 'wallcovering').trim() : `A delightful decorative wallcovering perfect for bedrooms, nurseries, and living spaces.`;
+    const newBody = `<p>${firstPara}</p>\n<p>${secondPara}</p>`;
+
+    try {
+      await gql({
+        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+        variables: { i: { id: pid, bodyHtml: newBody } }
+      });
+      bodiesOk++;
+      console.log(`  Body: 2 paragraphs set`);
+    } catch { errors++; }
+
+    // === 4. ROOM SETTINGS: 2 residential (bedroom + living room) ===
+    if (imgUrl) {
+      const tmpImg = '/tmp/ani_img.jpg';
+      if (!fs.existsSync(tmpImg) || fs.statSync(tmpImg).size < 1000) {
+        await downloadImage(imgUrl, tmpImg);
+      }
+
+      // Build tiled wall from the product image
+      try {
+        const dims = execFileSync('identify', ['-format', '%wx%h', tmpImg]).toString().trim();
+        const [imgW, imgH] = dims.split('x').map(Number);
+
+        // Tile horizontally (3 strips), crop to wall proportions
+        execFileSync('convert', [tmpImg, tmpImg, tmpImg, '+append', '-gravity', 'center', '-crop', `${imgW*3}x${Math.min(imgH, imgW*2)}+0+0`, '+repage', '-resize', '2000x1200', '-quality', '90', '/tmp/ani_wall.jpg']);
+
+        const rooms = [
+          { name: 'Bedroom', prompt: `This image is a wallcovering pattern tiled on a wall. Use it EXACTLY as the background wall. Place in front: a modern queen bed with white and sage green bedding, two wood nightstands with ceramic lamps, light oak hardwood floor. Warm morning light from right window. Photorealistic interior design magazine style.` },
+          { name: 'Living Room', prompt: `This image is a wallcovering pattern tiled on a wall. Use it EXACTLY as the background wall. Place in front: a mid-century modern sofa in cream linen, a round walnut coffee table, a potted fiddle leaf fig plant, and a soft area rug. Natural afternoon light. Photorealistic interior design photography.` },
+        ];
+
+        for (const room of rooms) {
+          console.log(`  Room: generating ${room.name}...`);
+          const imgBuf = await geminiGenerateImage('/tmp/ani_wall.jpg', room.prompt);
+          if (imgBuf) {
+            const mediaId = await uploadImageToShopify(pid, imgBuf, `${p.title} - ${room.name} room setting`);
+            if (mediaId) {
+              roomsOk++;
+              console.log(`  Room: ${room.name} uploaded (${(imgBuf.length/1024).toFixed(0)}KB)`);
+            }
+          } else {
+            console.log(`  Room: ${room.name} — Gemini returned no image`);
+          }
+          await sleep(2000); // Rate limit between image generations
+        }
+      } catch (e) {
+        console.log(`  Rooms: ERROR ${e.message?.slice(0,60)}`);
+        errors++;
+      }
+    }
+
+    await sleep(1000);
+  }
+
+  console.log('\n=== COMPLETE ===');
+  console.log(`Products: ${products.length}`);
+  console.log(`Specs migrated: ${specsOk}`);
+  console.log(`Colors analyzed: ${colorsOk}`);
+  console.log(`Bodies rewritten: ${bodiesOk}`);
+  console.log(`Room settings generated: ${roomsOk}`);
+  console.log(`Errors: ${errors}`);
+}
+
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 0000000..41e4d3e
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,75 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { validateCredentials, AUTH_CONFIG } from '@/lib/auth'
+import { cookies } from 'next/headers'
+
+export async function POST(request: NextRequest) {
+  try {
+    // Read raw body and parse manually for debugging
+    let rawBody = await request.text()
+
+    // Fix bash shell escaping issues (e.g., \! becomes !)
+    // JSON only allows these escape sequences: \" \\ \/ \b \f \n \r \t \uXXXX
+    // Remove backslashes before characters that aren't valid JSON escapes
+    // Handle both single backslash and double-escaped backslashes
+    rawBody = rawBody
+      .replace(/\\!/g, '!')  // Specifically handle \! -> !
+      .replace(/\\([^"\\/bfnrtu])/g, '$1')  // Handle other invalid escapes
+
+    let username: string, password: string
+    try {
+      const body = JSON.parse(rawBody)
+      username = body.username
+      password = body.password
+    } catch (parseErr) {
+      // If still failing, try alternative parsing approaches
+      console.error('JSON parse error:', parseErr, 'Raw body:', rawBody)
+
+      // Try extracting credentials with regex as fallback
+      const usernameMatch = rawBody.match(/"username"\s*:\s*"([^"]+)"/)
+      const passwordMatch = rawBody.match(/"password"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/)
+
+      if (usernameMatch && passwordMatch) {
+        username = usernameMatch[1]
+        // Unescape the password manually
+        password = passwordMatch[1].replace(/\\(.)/g, '$1')
+      } else {
+        return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+      }
+    }
+
+    if (!username || !password) {
+      return NextResponse.json({ error: 'Username and password required' }, { status: 400 })
+    }
+
+    if (!validateCredentials(username, password)) {
+      return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
+    }
+
+    // Create session token
+    const sessionData = {
+      authenticated: true,
+      username,
+      loginTime: Date.now()
+    }
+    const sessionToken = Buffer.from(JSON.stringify(sessionData)).toString('base64')
+
+    // Set cookie
+    const cookieStore = await cookies()
+    cookieStore.set(AUTH_CONFIG.sessionName, sessionToken, {
+      httpOnly: true,
+      secure: false, // Allow HTTP access for direct IP access
+      sameSite: 'lax',
+      maxAge: AUTH_CONFIG.cookieMaxAge / 1000, // Convert to seconds
+      path: '/'
+    })
+
+    return NextResponse.json({
+      success: true,
+      message: 'Login successful',
+      username
+    })
+  } catch (error) {
+    console.error('Login error:', error)
+    return NextResponse.json({ error: 'Login failed' }, { status: 500 })
+  }
+}
diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..66170ca
--- /dev/null
+++ b/app/api/auth/logout/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server'
+import { AUTH_CONFIG } from '@/lib/auth'
+import { cookies } from 'next/headers'
+
+export async function POST() {
+  try {
+    const cookieStore = await cookies()
+    cookieStore.delete(AUTH_CONFIG.sessionName)
+
+    return NextResponse.json({
+      success: true,
+      message: 'Logged out successfully'
+    })
+  } catch (error) {
+    console.error('Logout error:', error)
+    return NextResponse.json({ error: 'Logout failed' }, { status: 500 })
+  }
+}
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
new file mode 100644
index 0000000..3083c5d
--- /dev/null
+++ b/app/api/auth/session/route.ts
@@ -0,0 +1,50 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { AUTH_CONFIG, isWhitelistedIP } from '@/lib/auth'
+import { cookies } from 'next/headers'
+
+export async function GET(request: NextRequest) {
+  try {
+    // Check if IP is whitelisted
+    const forwardedFor = request.headers.get('x-forwarded-for')
+    const ip = forwardedFor?.split(',')[0]?.trim() || ''
+
+    // Also check request URL hostname for localhost
+    const url = new URL(request.url)
+    const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
+
+    if (isWhitelistedIP(ip) || isLocalhost) {
+      return NextResponse.json({
+        authenticated: true,
+        username: 'admin',
+        bypassReason: 'whitelisted_ip'
+      })
+    }
+
+    // Check session cookie
+    const cookieStore = await cookies()
+    const sessionCookie = cookieStore.get(AUTH_CONFIG.sessionName)
+
+    if (!sessionCookie?.value) {
+      return NextResponse.json({ authenticated: false }, { status: 401 })
+    }
+
+    try {
+      const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
+
+      if (sessionData.authenticated) {
+        return NextResponse.json({
+          authenticated: true,
+          username: sessionData.username,
+          loginTime: sessionData.loginTime
+        })
+      }
+    } catch {
+      // Invalid session format
+    }
+
+    return NextResponse.json({ authenticated: false }, { status: 401 })
+  } catch (error) {
+    console.error('Session check error:', error)
+    return NextResponse.json({ authenticated: false, error: 'Session check failed' }, { status: 500 })
+  }
+}
diff --git a/app/api/claude/suggest/route.ts b/app/api/claude/suggest/route.ts
new file mode 100644
index 0000000..961e9d8
--- /dev/null
+++ b/app/api/claude/suggest/route.ts
@@ -0,0 +1,185 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+import * as path from 'path'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string, maxTokens: number = 500): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.7, maxOutputTokens: maxTokens }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+// Helper to read project context files
+async function getProjectContext(projectName: string): Promise<string> {
+  const projectsDir = '/root/Projects'
+  const contextParts: string[] = []
+
+  // Find the project directory
+  const projectPath = path.join(projectsDir, projectName)
+  if (!fs.existsSync(projectPath)) {
+    // Try to find it in subdirectories
+    const dirs = fs.readdirSync(projectsDir)
+    for (const dir of dirs) {
+      const subPath = path.join(projectsDir, dir, projectName)
+      if (fs.existsSync(subPath)) {
+        return getProjectContextFromPath(subPath)
+      }
+    }
+    return ''
+  }
+
+  return getProjectContextFromPath(projectPath)
+}
+
+function getProjectContextFromPath(projectPath: string): string {
+  const contextParts: string[] = []
+
+  // Read CLAUDE.md if exists
+  const claudeMdPaths = [
+    path.join(projectPath, 'CLAUDE.md'),
+    path.join(projectPath, '.claude', 'CLAUDE.md')
+  ]
+
+  for (const claudePath of claudeMdPaths) {
+    if (fs.existsSync(claudePath)) {
+      try {
+        const content = fs.readFileSync(claudePath, 'utf-8')
+        contextParts.push(`\n### Project Documentation (CLAUDE.md):\n${content.slice(0, 2000)}`)
+        break
+      } catch {}
+    }
+  }
+
+  // Read package.json for tech context
+  const pkgPath = path.join(projectPath, 'package.json')
+  if (fs.existsSync(pkgPath)) {
+    try {
+      const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
+      const deps = Object.keys(pkg.dependencies || {}).slice(0, 10).join(', ')
+      contextParts.push(`\n### Tech Stack: ${deps}`)
+    } catch {}
+  }
+
+  // Look for existing PRD files
+  const tasksDirs = [
+    path.join(projectPath, 'tasks'),
+    path.join(projectPath, 'prd'),
+    path.join(projectPath, 'scripts', 'ralph')
+  ]
+
+  for (const tasksDir of tasksDirs) {
+    if (fs.existsSync(tasksDir)) {
+      try {
+        const files = fs.readdirSync(tasksDir)
+        const prdFiles = files.filter(f => f.endsWith('.md') && f.includes('prd'))
+        if (prdFiles.length > 0) {
+          const latestPrd = prdFiles[0]
+          const prdContent = fs.readFileSync(path.join(tasksDir, latestPrd), 'utf-8')
+          contextParts.push(`\n### Previous PRD Example (${latestPrd}):\n${prdContent.slice(0, 1500)}`)
+          break
+        }
+      } catch {}
+    }
+  }
+
+  return contextParts.join('\n')
+}
+
+const fieldPrompts: Record<string, string> = {
+  featureTitle: 'Suggest a clear, concise feature title that describes what this feature does.',
+  problemStatement: 'Write a clear problem statement explaining what issue this feature solves and why it matters to users.',
+  keyFeatures: 'List 3-5 key features as bullet points that would make this feature complete and useful.',
+  nonGoals: 'List 2-3 things that should be explicitly OUT of scope for this feature to keep it focused.',
+  successMetrics: 'Suggest 2-3 measurable success criteria that would indicate this feature is working well.',
+  integrations: 'Based on this feature, suggest relevant APIs or services that might be needed.',
+  techStack: 'Suggest the most appropriate tech stack for this type of feature.',
+  additionalAnswers: 'Provide any additional context or considerations for this feature.',
+  competitorUrls: `Based on this project and feature, suggest 3-5 similar apps, websites, or competitors that could serve as inspiration.
+Format each as:
+• [Name] - [URL] - [Brief description of what makes it relevant]
+
+Focus on well-known, successful products that solve similar problems. Include a mix of direct competitors and products with similar UX patterns.`,
+  appStoreUrls: `Based on this project and feature, suggest 3-5 mobile apps on the App Store or Google Play that could serve as inspiration.
+Format each as:
+• [App Name] - [Platform: iOS/Android/Both] - [Brief description of relevant features]
+
+Focus on popular, well-designed apps that have similar functionality or UX patterns worth studying.`
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let body: { projectName?: string; currentField?: string; currentQuestion?: string; existingData?: any }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const {
+      projectName,
+      currentField = '',
+      currentQuestion = '',
+      existingData = {}
+    } = body
+
+    const fieldPrompt = fieldPrompts[currentField] || `Provide a helpful suggestion for: ${currentQuestion}`
+
+    // Build context from existing data
+    const contextParts = []
+    if (existingData.projectName) contextParts.push(`Project: ${existingData.projectName}`)
+    if (existingData.featureTitle) contextParts.push(`Feature: ${existingData.featureTitle}`)
+    if (existingData.problemStatement) contextParts.push(`Problem: ${existingData.problemStatement}`)
+    if (existingData.targetUser) {
+      const userMap: Record<string, string> = { A: 'New users', B: 'Existing users', C: 'Admin users', D: 'All users' }
+      contextParts.push(`Target: ${userMap[existingData.targetUser] || existingData.targetUser}`)
+    }
+    if (existingData.scope) {
+      const scopeMap: Record<string, string> = { A: 'Minimal MVP', B: 'Full-featured', C: 'Backend only', D: 'Frontend only' }
+      contextParts.push(`Scope: ${scopeMap[existingData.scope] || existingData.scope}`)
+    }
+    if (existingData.keyFeatures) contextParts.push(`Key features: ${existingData.keyFeatures}`)
+
+    // Get project-specific context from CLAUDE.md and PRD files
+    const projectContext = await getProjectContext(projectName || existingData.projectName || '')
+
+    const context = contextParts.length > 0
+      ? `\n\nContext about this project:\n${contextParts.join('\n')}${projectContext}`
+      : projectContext
+
+    const prompt = `You are helping a developer plan a software feature. ${fieldPrompt}${context}
+
+Be concise and practical. If this is a text field, provide a single clear answer. If it's a list field, use bullet points starting with •.
+
+Use the project documentation and existing PRDs as reference for style and naming conventions.
+
+Only provide the suggested content, no explanations or meta-commentary.`
+
+    const suggestion = (await callGemini(prompt, 500)).trim() || 'Unable to generate suggestion'
+
+    return NextResponse.json({ suggestion })
+  } catch (error) {
+    console.error('Claude suggestion error:', error)
+    return NextResponse.json(
+      { error: 'Failed to get suggestion', suggestion: 'Unable to generate suggestion at this time.' },
+      { status: 500 }
+    )
+  }
+}
diff --git a/app/api/directory/route.ts b/app/api/directory/route.ts
new file mode 100644
index 0000000..00bce25
--- /dev/null
+++ b/app/api/directory/route.ts
@@ -0,0 +1,85 @@
+import { NextRequest, NextResponse } from 'next/server'
+import fs from 'fs'
+import path from 'path'
+
+interface DirectoryNode {
+  name: string
+  path: string
+  type: 'file' | 'directory'
+  children?: DirectoryNode[]
+}
+
+const IGNORE_PATTERNS = [
+  'node_modules',
+  '.git',
+  '.next',
+  '__pycache__',
+  '.cache',
+  'dist',
+  'build',
+  '.DS_Store',
+  'Thumbs.db'
+]
+
+function buildTree(dirPath: string, depth = 0, maxDepth = 3): DirectoryNode | null {
+  try {
+    const stats = fs.statSync(dirPath)
+    const name = path.basename(dirPath)
+
+    if (IGNORE_PATTERNS.includes(name)) {
+      return null
+    }
+
+    const node: DirectoryNode = {
+      name,
+      path: dirPath,
+      type: stats.isDirectory() ? 'directory' : 'file'
+    }
+
+    if (stats.isDirectory() && depth < maxDepth) {
+      const entries = fs.readdirSync(dirPath, { withFileTypes: true })
+      const children: DirectoryNode[] = []
+
+      for (const entry of entries) {
+        if (IGNORE_PATTERNS.includes(entry.name)) continue
+
+        const childPath = path.join(dirPath, entry.name)
+        const child = buildTree(childPath, depth + 1, maxDepth)
+        if (child) {
+          children.push(child)
+        }
+      }
+
+      // Sort: directories first, then files
+      children.sort((a, b) => {
+        if (a.type !== b.type) {
+          return a.type === 'directory' ? -1 : 1
+        }
+        return a.name.localeCompare(b.name)
+      })
+
+      node.children = children
+    }
+
+    return node
+  } catch (error) {
+    console.error('Error building tree for:', dirPath, error)
+    return null
+  }
+}
+
+export async function GET(request: NextRequest) {
+  const { searchParams } = new URL(request.url)
+  const dirPath = searchParams.get('path')
+
+  if (!dirPath) {
+    return NextResponse.json({ error: 'Path parameter required' }, { status: 400 })
+  }
+
+  if (!fs.existsSync(dirPath)) {
+    return NextResponse.json({ error: 'Directory not found' }, { status: 404 })
+  }
+
+  const tree = buildTree(dirPath)
+  return NextResponse.json(tree)
+}
diff --git a/app/api/files/route.ts b/app/api/files/route.ts
new file mode 100644
index 0000000..a3cdea0
--- /dev/null
+++ b/app/api/files/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+
+export async function GET(request: NextRequest) {
+  try {
+    const { searchParams } = new URL(request.url)
+    const filePath = searchParams.get('path')
+
+    if (!filePath) {
+      return NextResponse.json({ error: 'Path required' }, { status: 400 })
+    }
+
+    // Security: only allow reading from Projects directory
+    if (!filePath.startsWith('/root/Projects/') && !filePath.startsWith('/tmp/')) {
+      return NextResponse.json({ error: 'Access denied' }, { status: 403 })
+    }
+
+    if (!fs.existsSync(filePath)) {
+      return NextResponse.json({ error: 'File not found' }, { status: 404 })
+    }
+
+    const content = fs.readFileSync(filePath, 'utf-8')
+    return NextResponse.json({ content, path: filePath })
+  } catch (error: any) {
+    console.error('File read error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/live/check/route.ts b/app/api/live/check/route.ts
new file mode 100644
index 0000000..7273be7
--- /dev/null
+++ b/app/api/live/check/route.ts
@@ -0,0 +1,178 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { exec } from 'child_process'
+import { promisify } from 'util'
+
+const execAsync = promisify(exec)
+
+export async function POST(request: NextRequest) {
+  try {
+    const { pid, action } = await request.json()
+
+    if (!pid) {
+      return NextResponse.json({ error: 'PID required' }, { status: 400 })
+    }
+
+    // Check if process exists
+    let processExists = false
+    let processInfo: any = null
+
+    try {
+      const { stdout } = await execAsync(`ps -p ${pid} -o pid,state,pcpu,pmem,etime,comm --no-headers 2>/dev/null`)
+      if (stdout.trim()) {
+        const parts = stdout.trim().split(/\s+/)
+        processExists = true
+        processInfo = {
+          pid: parseInt(parts[0]),
+          state: parts[1],
+          cpu: parseFloat(parts[2]),
+          memory: parseFloat(parts[3]),
+          elapsed: parts[4],
+          command: parts.slice(5).join(' ')
+        }
+      }
+    } catch {
+      processExists = false
+    }
+
+    if (!processExists) {
+      return NextResponse.json({
+        status: 'dead',
+        message: `Process ${pid} is not running`,
+        processInfo: null
+      })
+    }
+
+    // Get detailed process info
+    let detailedInfo: any = {}
+
+    try {
+      // Get working directory
+      const { stdout: cwdOut } = await execAsync(`readlink -f /proc/${pid}/cwd 2>/dev/null || echo "unknown"`)
+      detailedInfo.cwd = cwdOut.trim()
+
+      // Get command line
+      const { stdout: cmdOut } = await execAsync(`cat /proc/${pid}/cmdline 2>/dev/null | tr '\\0' ' ' || echo "unknown"`)
+      detailedInfo.cmdline = cmdOut.trim()
+
+      // Get file descriptors count
+      const { stdout: fdOut } = await execAsync(`ls /proc/${pid}/fd 2>/dev/null | wc -l || echo "0"`)
+      detailedInfo.openFiles = parseInt(fdOut.trim())
+
+      // Get threads count
+      const { stdout: threadsOut } = await execAsync(`ls /proc/${pid}/task 2>/dev/null | wc -l || echo "0"`)
+      detailedInfo.threads = parseInt(threadsOut.trim())
+
+      // Get memory details
+      const { stdout: memOut } = await execAsync(`cat /proc/${pid}/status 2>/dev/null | grep -E "^(VmRSS|VmSize|VmPeak):" || echo ""`)
+      const memLines = memOut.trim().split('\n')
+      memLines.forEach(line => {
+        const [key, value] = line.split(':').map(s => s.trim())
+        if (key && value) {
+          detailedInfo[key] = value
+        }
+      })
+
+      // Check for stuck indicators
+      const stuckIndicators = []
+
+      // Check CPU usage over time
+      if (processInfo.cpu < 0.1 && processInfo.state === 'S') {
+        // Get recent activity
+        const { stdout: statOut } = await execAsync(`cat /proc/${pid}/stat 2>/dev/null || echo ""`)
+        const statParts = statOut.split(' ')
+        if (statParts.length > 13) {
+          const utime = parseInt(statParts[13])
+          const stime = parseInt(statParts[14])
+          detailedInfo.cpuTicks = { user: utime, system: stime }
+        }
+      }
+
+      // Check if it's a ralph process and get its status
+      if (detailedInfo.cmdline.includes('ralph') || detailedInfo.cwd.includes('ralph')) {
+        try {
+          const { stdout: ralphOut } = await execAsync(`curl -s http://localhost:7300/api/ralph/status 2>/dev/null`)
+          const ralphStatus = JSON.parse(ralphOut)
+          detailedInfo.ralphStatus = {
+            running: ralphStatus.running,
+            currentStory: ralphStatus.currentStory,
+            progress: `${Object.values(ralphStatus.storyResults || {}).filter((s: any) => s === 'pass').length}/${Object.keys(ralphStatus.storyResults || {}).length}`,
+            lastLog: ralphStatus.logs?.slice(-3) || []
+          }
+
+          // Check if ralph is stuck (same story for too long with no progress)
+          if (ralphStatus.running && ralphStatus.elapsed > 3600) {
+            stuckIndicators.push('Ralph has been running for over an hour on the same story')
+          }
+        } catch {}
+      }
+
+      detailedInfo.stuckIndicators = stuckIndicators
+
+    } catch (err) {
+      console.error('Error getting detailed info:', err)
+    }
+
+    // Determine if process appears stuck
+    const isStuck =
+      processInfo.state === 'D' || // Uninterruptible sleep (usually I/O)
+      processInfo.state === 'T' || // Stopped
+      processInfo.state === 'Z' || // Zombie
+      (detailedInfo.stuckIndicators && detailedInfo.stuckIndicators.length > 0)
+
+    // Handle restart action
+    if (action === 'restart') {
+      try {
+        // Kill the process
+        await execAsync(`kill -9 ${pid}`)
+
+        // If it's ralph, try to restart it
+        if (detailedInfo.cmdline?.includes('ralph') || detailedInfo.cwd?.includes('ralph')) {
+          // Get the project path from ralph status or cwd
+          const projectPath = detailedInfo.cwd || '/root/Projects/angels-flowers'
+
+          return NextResponse.json({
+            status: 'restarted',
+            message: `Process ${pid} was killed. Ralph processes need to be restarted manually via the UI.`,
+            processInfo,
+            detailedInfo
+          })
+        }
+
+        return NextResponse.json({
+          status: 'killed',
+          message: `Process ${pid} was terminated`,
+          processInfo,
+          detailedInfo
+        })
+      } catch (err: any) {
+        return NextResponse.json({
+          status: 'error',
+          message: `Failed to kill process: ${err.message}`,
+          processInfo,
+          detailedInfo
+        })
+      }
+    }
+
+    return NextResponse.json({
+      status: isStuck ? 'stuck' : 'running',
+      message: isStuck
+        ? `Process ${pid} appears to be stuck (state: ${processInfo.state})`
+        : `Process ${pid} is running normally`,
+      processInfo,
+      detailedInfo,
+      stateDescription: ({
+        'R': 'Running',
+        'S': 'Sleeping (waiting for event)',
+        'D': 'Uninterruptible sleep (usually I/O)',
+        'T': 'Stopped',
+        'Z': 'Zombie',
+        'X': 'Dead'
+      } as Record<string, string>)[processInfo.state] || processInfo.state
+    })
+
+  } catch (error: any) {
+    console.error('Check process error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/live/route.ts b/app/api/live/route.ts
new file mode 100644
index 0000000..e046683
--- /dev/null
+++ b/app/api/live/route.ts
@@ -0,0 +1,241 @@
+import { NextResponse } from 'next/server'
+import { exec } from 'child_process'
+import { promisify } from 'util'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const execAsync = promisify(exec)
+
+interface ClaudeProcess {
+  pid: number
+  cpu: string
+  memory: string
+  terminal: string
+  uptime: string
+  command: string
+  type: 'interactive' | 'ralph' | 'mcp' | 'unknown'
+  project?: string
+  status: string
+}
+
+interface RalphStatus {
+  running: boolean
+  project: string
+  projectPath: string
+  branch: string
+  currentStory: string
+  progress: string
+  completedStories: string[]
+  pendingStories: string[]
+  runningStory: string | null
+  logs: string[]
+  elapsed: number
+  storyDetails: Array<{
+    id: string
+    title: string
+    status: 'done' | 'running' | 'pending' | 'fail'
+    time?: number
+  }>
+}
+
+export async function GET() {
+  try {
+    // Get all Claude processes
+    const { stdout: psOutput } = await execAsync(
+      `ps aux | grep -E "claude|anthropic" | grep -v grep || true`
+    )
+
+    const claudeProcesses: ClaudeProcess[] = []
+    const lines = psOutput.trim().split('\n').filter(Boolean)
+
+    for (const line of lines) {
+      const parts = line.split(/\s+/)
+      if (parts.length < 11) continue
+
+      const pid = parseInt(parts[1])
+      const cpu = parts[2]
+      const mem = parts[3]
+      const terminal = parts[6]
+      const command = parts.slice(10).join(' ')
+
+      // Determine type
+      let type: ClaudeProcess['type'] = 'unknown'
+      let project = ''
+
+      if (command.includes('--claude-in-chrome-mcp')) {
+        type = 'mcp'
+      } else if (parts[0] === 'ralph') {
+        type = 'ralph'
+        // Try to get project from ralph
+        try {
+          const { stdout } = await execAsync(`ls -la /proc/${pid}/cwd 2>/dev/null || true`)
+          const match = stdout.match(/-> (.+)/)
+          if (match) project = match[1]
+        } catch {}
+      } else if (command === 'claude' || command.includes('claude-code')) {
+        type = 'interactive'
+      }
+
+      // Get uptime
+      let uptime = 'unknown'
+      try {
+        const { stdout } = await execAsync(`ps -o etime= -p ${pid} 2>/dev/null || true`)
+        uptime = stdout.trim() || 'unknown'
+      } catch {}
+
+      claudeProcesses.push({
+        pid,
+        cpu,
+        memory: mem,
+        terminal,
+        uptime,
+        command: command.slice(0, 100),
+        type,
+        project,
+        status: 'running'
+      })
+    }
+
+    // Get Ralph status for all projects with progress.txt
+    const ralphProjects: RalphStatus[] = []
+
+    // Scan for projects with Ralph progress files
+    const projectDirs = [
+      '/root/Projects/jill-website',
+      '/root/Projects/angels-flowers',
+      '/root/Projects/Designer-Wallcoverings',
+      '/root/Projects/dear-bubbe-nextjs',
+      '/root/Projects/Letsbegin'
+    ]
+
+    for (const projectPath of projectDirs) {
+      try {
+        const progressPath = path.join(projectPath, 'scripts/ralph/progress.txt')
+        const prdPath = path.join(projectPath, 'scripts/ralph/prd.json')
+
+        if (!fs.existsSync(progressPath)) continue
+
+        const progressContent = fs.readFileSync(progressPath, 'utf-8')
+        const lines = progressContent.split('\n')
+
+        // Parse progress.txt header
+        let branch = ''
+        let startTime = ''
+        const branchMatch = progressContent.match(/# Branch: (.+)/)
+        const startMatch = progressContent.match(/# Started: (.+)/)
+        if (branchMatch) branch = branchMatch[1]
+        if (startMatch) startTime = startMatch[1]
+
+        // Parse story statuses directly from progress.txt
+        const storyResults: Record<string, 'done' | 'running' | 'pending' | 'fail'> = {}
+        const storyDetails: RalphStatus['storyDetails'] = []
+
+        for (const line of lines) {
+          const match = line.match(/^(US-\d+):\s*(DONE|PASS|FAIL|RUNNING|PENDING)\s*-?\s*(.*)/)
+          if (match) {
+            const storyId = match[1]
+            const status = match[2]
+            const title = match[3] || storyId
+
+            let mappedStatus: 'done' | 'running' | 'pending' | 'fail' = 'pending'
+            if (status === 'DONE' || status === 'PASS') mappedStatus = 'done'
+            else if (status === 'FAIL') mappedStatus = 'fail'
+            else if (status === 'RUNNING') mappedStatus = 'running'
+
+            storyResults[storyId] = mappedStatus
+            storyDetails.push({ id: storyId, title, status: mappedStatus })
+          }
+        }
+
+        // If PRD exists, get full titles
+        if (fs.existsSync(prdPath)) {
+          try {
+            const prd = JSON.parse(fs.readFileSync(prdPath, 'utf-8'))
+            for (const story of prd.userStories || []) {
+              const existing = storyDetails.find(s => s.id === story.id)
+              if (existing) {
+                existing.title = story.title
+              }
+            }
+          } catch {}
+        }
+
+        const completed = storyDetails.filter(s => s.status === 'done').length
+        const total = storyDetails.length
+        const running = storyDetails.find(s => s.status === 'running')
+        const isRunning = running !== undefined
+
+        // Check if Ralph process is actually running for this project
+        let hasActiveProcess = false
+        try {
+          const { stdout } = await execAsync(`ps aux | grep ralph | grep -v grep | grep "${path.basename(projectPath)}" || true`)
+          hasActiveProcess = stdout.trim().length > 0
+        } catch {}
+
+        // Also check the log file for recent activity
+        const logPath = '/tmp/ralph-output.log'
+        let logLines: string[] = []
+        if (fs.existsSync(logPath)) {
+          const logContent = fs.readFileSync(logPath, 'utf-8')
+          logLines = logContent.split('\n').slice(-50)
+        }
+
+        ralphProjects.push({
+          running: isRunning || hasActiveProcess,
+          project: path.basename(projectPath),
+          projectPath,
+          branch,
+          currentStory: running?.id || storyDetails[storyDetails.length - 1]?.id || '',
+          progress: `${completed}/${total}`,
+          completedStories: storyDetails.filter(s => s.status === 'done').map(s => s.id),
+          pendingStories: storyDetails.filter(s => s.status === 'pending').map(s => s.id),
+          runningStory: running?.id || null,
+          logs: logLines,
+          elapsed: startTime ? Math.floor((Date.now() - new Date(startTime).getTime()) / 1000) : 0,
+          storyDetails
+        })
+      } catch (err) {
+        console.error(`Failed to read Ralph status for ${projectPath}:`, err)
+      }
+    }
+
+    // Use the first project as primary ralph status (for backwards compatibility)
+    const ralphStatus = ralphProjects.length > 0 ? ralphProjects[0] : null
+
+    // Get system stats
+    let systemStats = {
+      loadAvg: [0, 0, 0],
+      memUsed: 0,
+      memTotal: 0,
+      uptime: ''
+    }
+
+    try {
+      const { stdout: loadAvg } = await execAsync('cat /proc/loadavg')
+      const loads = loadAvg.trim().split(' ')
+      systemStats.loadAvg = [parseFloat(loads[0]), parseFloat(loads[1]), parseFloat(loads[2])]
+
+      const { stdout: memInfo } = await execAsync('free -m | grep Mem')
+      const memParts = memInfo.trim().split(/\s+/)
+      systemStats.memTotal = parseInt(memParts[1])
+      systemStats.memUsed = parseInt(memParts[2])
+
+      const { stdout: uptime } = await execAsync('uptime -p')
+      systemStats.uptime = uptime.trim()
+    } catch {}
+
+    return NextResponse.json({
+      timestamp: new Date().toISOString(),
+      claudeProcesses,
+      ralphStatus,
+      ralphProjects,  // All projects with Ralph progress
+      systemStats,
+      totalProcesses: claudeProcesses.length,
+      activeRalph: ralphProjects.some(p => p.running)
+    })
+
+  } catch (error) {
+    console.error('Live API error:', error)
+    return NextResponse.json({ error: 'Failed to get live status' }, { status: 500 })
+  }
+}
diff --git a/app/api/prd/clarify/route.ts b/app/api/prd/clarify/route.ts
new file mode 100644
index 0000000..7ad2a38
--- /dev/null
+++ b/app/api/prd/clarify/route.ts
@@ -0,0 +1,149 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.7, maxOutputTokens: 2000 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+interface PRDInput {
+  projectName: string
+  featureTitle: string
+  problemStatement: string
+  targetUser: string
+  scope: string
+  priority: string
+  keyFeatures: string
+  nonGoals: string
+  successMetrics: string
+  components: string[]
+  integrations: string
+  techStack: string
+  additionalAnswers: string
+  projectPath?: string
+}
+
+const targetUserMap: Record<string, string> = {
+  'A': 'New users',
+  'B': 'Existing users',
+  'C': 'Admin users',
+  'D': 'All users'
+}
+
+const scopeMap: Record<string, string> = {
+  'A': 'Minimal viable implementation',
+  'B': 'Full-featured implementation',
+  'C': 'Backend/API only',
+  'D': 'Frontend/UI only'
+}
+
+const priorityMap: Record<string, string> = {
+  'A': 'Critical (ASAP)',
+  'B': 'High priority',
+  'C': 'Medium priority',
+  'D': 'Low priority (nice-to-have)'
+}
+
+interface ClarifyingQuestion {
+  id: string
+  question: string
+  options: { key: string; label: string }[]
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let input: PRDInput
+    try {
+      input = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+
+    // Build context from the questionnaire
+    const context = `
+Project: ${input.projectName}
+Feature: ${input.featureTitle}
+Problem: ${input.problemStatement}
+Target User: ${targetUserMap[input.targetUser] || input.targetUser}
+Scope: ${scopeMap[input.scope] || input.scope}
+Priority: ${priorityMap[input.priority] || input.priority}
+Key Features: ${input.keyFeatures || 'Not specified'}
+Non-Goals: ${input.nonGoals || 'Not specified'}
+Success Metrics: ${input.successMetrics || 'Not specified'}
+Tech Stack: ${input.techStack || 'Not specified'}
+Integrations: ${input.integrations || 'None'}
+`
+
+    const prompt = `You are a PRD (Product Requirements Document) generation assistant. Based on the following feature information, generate 3-5 clarifying questions that would help create a more specific and actionable PRD.
+
+${context}
+
+Generate questions that:
+1. Resolve ambiguities about the problem or solution
+2. Clarify technical constraints or requirements
+3. Identify edge cases or error handling needs
+4. Determine success criteria specifics
+5. Understand integration or dependency details
+
+For each question, provide exactly 4 options (A, B, C, D) that represent different approaches or answers.
+
+IMPORTANT: Return ONLY a JSON array with this exact structure:
+[
+  {
+    "id": "q1",
+    "question": "How should the system handle users who...",
+    "options": [
+      {"key": "A", "label": "Option A description"},
+      {"key": "B", "label": "Option B description"},
+      {"key": "C", "label": "Option C description"},
+      {"key": "D", "label": "Option D description"}
+    ]
+  }
+]
+
+Return ONLY the JSON array, no other text or explanation.`
+
+    const responseText = await callGemini(prompt)
+
+    // Parse the JSON response
+    let questions: ClarifyingQuestion[] = []
+    try {
+      // Try to find JSON array in the response
+      const jsonMatch = responseText.match(/\[[\s\S]*\]/)
+      if (jsonMatch) {
+        questions = JSON.parse(jsonMatch[0])
+      } else {
+        questions = JSON.parse(responseText)
+      }
+    } catch (parseErr) {
+      console.error('Failed to parse clarifying questions:', parseErr, 'Response:', responseText)
+      // Return empty array - will skip to generation
+      questions = []
+    }
+
+    return NextResponse.json({ questions, success: true })
+  } catch (error) {
+    console.error('Failed to generate clarifying questions:', error)
+    return NextResponse.json({ error: 'Failed to generate clarifying questions', questions: [] }, { status: 500 })
+  }
+}
diff --git a/app/api/prd/generate/route.ts b/app/api/prd/generate/route.ts
new file mode 100644
index 0000000..0b58b7b
--- /dev/null
+++ b/app/api/prd/generate/route.ts
@@ -0,0 +1,163 @@
+import { NextRequest, NextResponse } from 'next/server'
+import fs from 'fs'
+import path from 'path'
+import { trackEvent } from '@/lib/sheets'
+import { autoCommit } from '@/lib/git'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.7, maxOutputTokens: 4000 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+interface PRDInput {
+  projectName: string
+  featureTitle: string
+  problemStatement: string
+  targetUser: string
+  scope: string
+  priority: string
+  keyFeatures: string
+  nonGoals: string
+  successMetrics: string
+  components: string[]
+  integrations: string
+  techStack: string
+  additionalAnswers: string
+  projectPath?: string
+}
+
+const targetUserMap: Record<string, string> = {
+  'A': 'New users',
+  'B': 'Existing users',
+  'C': 'Admin users',
+  'D': 'All users'
+}
+
+const scopeMap: Record<string, string> = {
+  'A': 'Minimal viable implementation',
+  'B': 'Full-featured implementation',
+  'C': 'Backend/API only',
+  'D': 'Frontend/UI only'
+}
+
+const priorityMap: Record<string, string> = {
+  'A': 'Critical (ASAP)',
+  'B': 'High priority',
+  'C': 'Medium priority',
+  'D': 'Low priority (nice-to-have)'
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let input: PRDInput
+    try {
+      input = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+
+    const prompt = `Generate a detailed Product Requirements Document (PRD) for the following feature:
+
+**Project**: ${input.projectName}
+**Feature Title**: ${input.featureTitle}
+**Problem Statement**: ${input.problemStatement}
+
+**Clarifying Questions Answered**:
+- Q1 (Target User): ${targetUserMap[input.targetUser] || input.targetUser}
+- Q2 (Scope): ${scopeMap[input.scope] || input.scope}
+- Q3 (Priority): ${priorityMap[input.priority] || input.priority}
+
+**Key Features/Requirements**:
+${input.keyFeatures || 'Not specified'}
+
+**Non-Goals (Out of Scope)**:
+${input.nonGoals || 'Not specified'}
+
+**Success Metrics**:
+${input.successMetrics || 'Not specified'}
+
+**Technical Context**:
+- Tech Stack: ${input.techStack}
+- Integrations: ${input.integrations || 'None specified'}
+
+**Additional Notes**:
+${input.additionalAnswers || 'None'}
+
+Generate a PRD with the following sections:
+1. Introduction/Overview
+2. Goals (bullet list)
+3. User Stories (format: US-001, US-002, etc. with Description and Acceptance Criteria)
+4. Functional Requirements (FR-1, FR-2, etc.)
+5. Non-Goals
+6. Design Considerations
+7. Technical Considerations
+8. Success Metrics
+9. Open Questions
+
+IMPORTANT:
+- Each user story should be small enough to implement in ONE focused session
+- Acceptance criteria must be VERIFIABLE (not vague like "works correctly")
+- For UI stories, include "Verify in browser using dev-browser skill" as acceptance criterion
+- Always include "Typecheck passes" as acceptance criterion
+- Write for junior developers or AI agents - be explicit, avoid jargon
+
+Format as markdown.`
+
+    const prd = await callGemini(prompt)
+
+    // Save to file if project path provided
+    let prdFilePath = ''
+    if (input.projectPath && prd) {
+      const tasksDir = path.join(input.projectPath, 'tasks')
+      if (!fs.existsSync(tasksDir)) {
+        fs.mkdirSync(tasksDir, { recursive: true })
+      }
+      const kebabTitle = input.featureTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
+      prdFilePath = path.join(tasksDir, `prd-${kebabTitle}.md`)
+      fs.writeFileSync(prdFilePath, prd)
+
+      // Track the event
+      await trackEvent({
+        timestamp: new Date().toISOString(),
+        project: input.projectName,
+        step: 'prd_generated',
+        details: `Generated PRD: ${input.featureTitle}`,
+        status: 'success'
+      })
+
+      // Auto-commit the PRD
+      const commitResult = await autoCommit(
+        input.projectPath,
+        `docs: Add PRD for ${input.featureTitle}`,
+        [prdFilePath]
+      )
+      console.log('Auto-commit result:', commitResult)
+    }
+
+    return NextResponse.json({ prd, prdFilePath, success: true })
+  } catch (error) {
+    console.error('Failed to generate PRD:', error)
+    return NextResponse.json({ error: 'Failed to generate PRD' }, { status: 500 })
+  }
+}
diff --git a/app/api/processes/analyze/route.ts b/app/api/processes/analyze/route.ts
new file mode 100644
index 0000000..84ef32f
--- /dev/null
+++ b/app/api/processes/analyze/route.ts
@@ -0,0 +1,144 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+import * as path from 'path'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.3, maxOutputTokens: 1500 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let body: { projectPath?: string; project?: string }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const { projectPath, project } = body
+
+    if (!projectPath || !project) {
+      return NextResponse.json({ error: 'Missing projectPath or project' }, { status: 400 })
+    }
+
+    // Collect error information from various sources
+    const errorSources: string[] = []
+
+    // 1. Check progress.txt for failed stories
+    const progressFile = path.join(projectPath, 'scripts', 'ralph', 'progress.txt')
+    if (fs.existsSync(progressFile)) {
+      const content = fs.readFileSync(progressFile, 'utf-8')
+      errorSources.push('=== PROGRESS FILE ===')
+      errorSources.push(content)
+      errorSources.push('')
+    }
+
+    // 2. Check ralph state file
+    const stateFile = '/tmp/ralph-state.json'
+    if (fs.existsSync(stateFile)) {
+      try {
+        const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'))
+        if (state.project === project || state.projectPath === projectPath) {
+          errorSources.push('=== RALPH STATE ===')
+          errorSources.push(`Status: ${state.running ? 'Running' : 'Stopped'}`)
+          errorSources.push(`Story Results: ${JSON.stringify(state.storyResults, null, 2)}`)
+          if (state.logs?.length) {
+            errorSources.push('\nRecent Logs:')
+            errorSources.push(state.logs.slice(-50).join('\n'))
+          }
+          errorSources.push('')
+        }
+      } catch {}
+    }
+
+    // 3. Check for any error logs in the project
+    const errorLogPaths = [
+      path.join(projectPath, 'ralph-error.log'),
+      path.join(projectPath, 'scripts', 'ralph', 'error.log'),
+      path.join(projectPath, '.ralph', 'error.log')
+    ]
+
+    for (const logPath of errorLogPaths) {
+      if (fs.existsSync(logPath)) {
+        errorSources.push(`=== ${path.basename(logPath)} ===`)
+        const content = fs.readFileSync(logPath, 'utf-8')
+        // Get last 100 lines
+        const lines = content.split('\n').slice(-100)
+        errorSources.push(lines.join('\n'))
+        errorSources.push('')
+      }
+    }
+
+    // 4. Check npm/build errors
+    const buildErrorPaths = [
+      path.join(projectPath, 'npm-debug.log'),
+      path.join(projectPath, '.next', 'error.log')
+    ]
+
+    for (const logPath of buildErrorPaths) {
+      if (fs.existsSync(logPath)) {
+        errorSources.push(`=== ${path.basename(logPath)} ===`)
+        const content = fs.readFileSync(logPath, 'utf-8')
+        const lines = content.split('\n').slice(-50)
+        errorSources.push(lines.join('\n'))
+        errorSources.push('')
+      }
+    }
+
+    if (errorSources.length === 0) {
+      return NextResponse.json({
+        analysis: `No error logs found for project "${project}".\n\nChecked locations:\n- ${progressFile}\n- ${stateFile}\n- Various error log paths\n\nThe process may have failed without generating error logs, or the logs may have been cleared.`
+      })
+    }
+
+    // Use Gemini to analyze the errors
+    const errorContext = errorSources.join('\n')
+
+    try {
+      const aiPrompt = `Analyze these error logs from a Ralph (automated code generator) process that failed. Identify:
+1. The root cause of the failure
+2. Which user story or step failed
+3. Specific error messages and their meaning
+4. Recommended fixes
+
+Keep your response concise and actionable.
+
+Error Logs:
+${errorContext}`
+
+      const analysis = await callGemini(aiPrompt) || 'Unable to generate analysis'
+      return NextResponse.json({ analysis })
+
+    } catch (aiError: any) {
+      // If AI fails, return raw errors
+      return NextResponse.json({
+        analysis: `=== ERROR ANALYSIS (AI Error: ${aiError.message}) ===\n\n${errorContext}`
+      })
+    }
+
+  } catch (error: any) {
+    console.error('Analyze error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/processes/details/route.ts b/app/api/processes/details/route.ts
new file mode 100644
index 0000000..4e4e814
--- /dev/null
+++ b/app/api/processes/details/route.ts
@@ -0,0 +1,193 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+// Detect port from project config files
+function detectPort(projectPath: string): number | null {
+  // 1. Check ecosystem.config.js
+  const ecosystemPath = path.join(projectPath, 'ecosystem.config.js')
+  if (fs.existsSync(ecosystemPath)) {
+    try {
+      const content = fs.readFileSync(ecosystemPath, 'utf-8')
+      const portMatch = content.match(/PORT[:\s]*['"]?(\d+)['"]?/)
+      if (portMatch) return parseInt(portMatch[1])
+    } catch {}
+  }
+
+  // 2. Check package.json scripts
+  const packagePath = path.join(projectPath, 'package.json')
+  if (fs.existsSync(packagePath)) {
+    try {
+      const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf-8'))
+      const scripts = pkg.scripts || {}
+      for (const script of [scripts.start, scripts.dev]) {
+        if (script) {
+          const portMatch = script.match(/-p\s*(\d+)|--port[=\s]*(\d+)|PORT[=\s]*(\d+)/)
+          if (portMatch) return parseInt(portMatch[1] || portMatch[2] || portMatch[3])
+        }
+      }
+    } catch {}
+  }
+
+  // 3. Check .env or .env.local
+  for (const envFile of ['.env.local', '.env']) {
+    const envPath = path.join(projectPath, envFile)
+    if (fs.existsSync(envPath)) {
+      try {
+        const content = fs.readFileSync(envPath, 'utf-8')
+        const portMatch = content.match(/^PORT[=\s]*(\d+)/m)
+        if (portMatch) return parseInt(portMatch[1])
+      } catch {}
+    }
+  }
+
+  return null
+}
+
+export async function GET(request: NextRequest) {
+  try {
+    const { searchParams } = new URL(request.url)
+    const project = searchParams.get('project')
+    const projectPath = searchParams.get('path')
+
+    if (!project || !projectPath) {
+      return NextResponse.json({ error: 'Missing project or path parameter' }, { status: 400 })
+    }
+
+    const logs: string[] = []
+    const stories: { id: string; title: string; status: string; notes?: string }[] = []
+
+    // 1. Check ralph state file for logs
+    if (fs.existsSync(STATE_FILE)) {
+      try {
+        const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+        if (state.project === project || state.projectPath === projectPath) {
+          if (state.logs && Array.isArray(state.logs)) {
+            logs.push(...state.logs)
+          }
+
+          // Get stories from state
+          if (state.userStories && Array.isArray(state.userStories)) {
+            for (const story of state.userStories) {
+              stories.push({
+                id: story.id,
+                title: story.title || story.description || '',
+                status: state.storyResults?.[story.id] || 'PENDING',
+                notes: state.storyNotes?.[story.id] || ''
+              })
+            }
+          }
+        }
+      } catch (e) {
+        logs.push(`Error reading state file: ${e}`)
+      }
+    }
+
+    // 2. Read progress.txt for story statuses
+    const progressFile = path.join(projectPath, 'scripts', 'ralph', 'progress.txt')
+    if (fs.existsSync(progressFile)) {
+      try {
+        const content = fs.readFileSync(progressFile, 'utf-8')
+        logs.push('=== progress.txt ===')
+        logs.push(content)
+        logs.push('')
+
+        // Parse stories from progress file if not already loaded
+        if (stories.length === 0) {
+          const lines = content.split('\n')
+          for (const line of lines) {
+            const match = line.match(/(US-\d+):\s*(\w+)\s*-?\s*(.*)/)
+            if (match) {
+              stories.push({
+                id: match[1],
+                status: match[2],
+                title: match[3] || ''
+              })
+            }
+          }
+        }
+      } catch (e) {
+        logs.push(`Error reading progress.txt: ${e}`)
+      }
+    }
+
+    // 3. Read prd.json for story details if stories still empty
+    const prdFile = path.join(projectPath, 'scripts', 'ralph', 'prd.json')
+    if (stories.length === 0 && fs.existsSync(prdFile)) {
+      try {
+        const prdData = JSON.parse(fs.readFileSync(prdFile, 'utf-8'))
+        if (prdData.userStories && Array.isArray(prdData.userStories)) {
+          for (const story of prdData.userStories) {
+            stories.push({
+              id: story.id,
+              title: story.title || story.description || '',
+              status: 'PENDING',
+              notes: ''
+            })
+          }
+        }
+      } catch (e) {
+        logs.push(`Error reading prd.json: ${e}`)
+      }
+    }
+
+    // 4. Check for error logs
+    const errorLogPaths = [
+      path.join(projectPath, 'ralph-error.log'),
+      path.join(projectPath, 'scripts', 'ralph', 'error.log'),
+      path.join(projectPath, '.ralph', 'error.log')
+    ]
+
+    for (const logPath of errorLogPaths) {
+      if (fs.existsSync(logPath)) {
+        try {
+          const content = fs.readFileSync(logPath, 'utf-8')
+          logs.push(`=== ${path.basename(logPath)} ===`)
+          const lines = content.split('\n').slice(-50) // Last 50 lines
+          logs.push(...lines)
+          logs.push('')
+        } catch (e) {
+          logs.push(`Error reading ${logPath}: ${e}`)
+        }
+      }
+    }
+
+    // 5. Check ralph output log
+    const outputLog = path.join(projectPath, 'scripts', 'ralph', 'output.log')
+    if (fs.existsSync(outputLog)) {
+      try {
+        const content = fs.readFileSync(outputLog, 'utf-8')
+        logs.push('=== output.log ===')
+        const lines = content.split('\n').slice(-100) // Last 100 lines
+        logs.push(...lines)
+      } catch (e) {
+        logs.push(`Error reading output.log: ${e}`)
+      }
+    }
+
+    // If no logs found, add a message
+    if (logs.length === 0) {
+      logs.push('No logs found for this process.')
+      logs.push(`Checked: ${progressFile}`)
+      logs.push(`Checked: ${STATE_FILE}`)
+      logs.push(`Project path: ${projectPath}`)
+    }
+
+    // Detect port for this project
+    const port = detectPort(projectPath)
+
+    return NextResponse.json({
+      logs,
+      stories,
+      project,
+      projectPath,
+      port
+    })
+
+  } catch (error: any) {
+    console.error('Process details error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/processes/fix/route.ts b/app/api/processes/fix/route.ts
new file mode 100644
index 0000000..3a42fbc
--- /dev/null
+++ b/app/api/processes/fix/route.ts
@@ -0,0 +1,285 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+import * as path from 'path'
+import { execSync, spawn } from 'child_process'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.3, maxOutputTokens: 2000 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+export async function POST(request: NextRequest) {
+  try {
+    let body: { projectPath?: string; project?: string; analysis?: string; attempt?: number }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const { projectPath, project, analysis, attempt = 1 } = body
+
+    if (!projectPath || !project) {
+      return NextResponse.json({ error: 'Missing projectPath or project' }, { status: 400 })
+    }
+
+    const logs: string[] = [`=== FIX ATTEMPT ${attempt} ===`]
+    logs.push(`Project: ${project}`)
+    logs.push(`Time: ${new Date().toISOString()}`)
+    logs.push('')
+
+    // Collect error context
+    let errorContext = analysis || ''
+
+    // Read progress.txt for current state
+    const progressFile = path.join(projectPath, 'scripts', 'ralph', 'progress.txt')
+    if (fs.existsSync(progressFile)) {
+      errorContext += '\n\n=== PROGRESS FILE ===\n' + fs.readFileSync(progressFile, 'utf-8')
+    }
+
+    // Read any error logs
+    const errorLogPaths = [
+      path.join(projectPath, 'ralph-error.log'),
+      path.join(projectPath, 'scripts', 'ralph', 'error.log'),
+    ]
+    for (const logPath of errorLogPaths) {
+      if (fs.existsSync(logPath)) {
+        const content = fs.readFileSync(logPath, 'utf-8').split('\n').slice(-50).join('\n')
+        errorContext += `\n\n=== ${path.basename(logPath)} ===\n${content}`
+      }
+    }
+
+    // Read prd.json to understand the task
+    const prdFile = path.join(projectPath, 'scripts', 'ralph', 'prd.json')
+    let prdData: any = null
+    if (fs.existsSync(prdFile)) {
+      prdData = JSON.parse(fs.readFileSync(prdFile, 'utf-8'))
+      errorContext += `\n\n=== PRD SUMMARY ===\nProject: ${prdData.project}\nStories: ${prdData.userStories?.length || 0}`
+    }
+
+    // Use Gemini to analyze and generate fix commands
+    logs.push('Analyzing errors with Gemini...')
+
+    const aiPrompt = `You are a debugging expert. Analyze these errors from a Ralph (automated code generator) process and provide SPECIFIC fix commands.
+
+PROJECT PATH: ${projectPath}
+PROJECT: ${project}
+ATTEMPT: ${attempt}
+
+ERROR CONTEXT:
+${errorContext}
+
+Respond with a JSON object containing:
+1. "diagnosis": Brief explanation of the root cause
+2. "fixes": Array of fix objects, each with:
+   - "type": "command" | "file_edit" | "file_create" | "file_delete"
+   - "description": What this fix does
+   - For "command": "command": the bash command to run
+   - For "file_edit": "path", "find", "replace"
+   - For "file_create": "path", "content"
+   - For "file_delete": "path"
+3. "should_restart": boolean - whether to restart Ralph after fixes
+4. "confidence": "high" | "medium" | "low"
+
+Common fixes:
+- Missing dependencies: npm install
+- Type errors: Fix the specific type issue
+- Missing files: Create them
+- Permission issues: chmod
+- Port conflicts: Kill process on port
+- Stalled stories: Reset story status in progress.txt
+
+IMPORTANT: Only suggest safe, reversible fixes. Never suggest destructive commands.
+Respond ONLY with valid JSON, no markdown.`
+
+    const aiResponse = await callGemini(aiPrompt)
+    logs.push('Claude response received')
+
+    let fixPlan: any
+    try {
+      // Clean up response - remove any markdown formatting
+      const cleanJson = aiResponse.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim()
+      fixPlan = JSON.parse(cleanJson)
+    } catch (e) {
+      logs.push(`Failed to parse fix plan: ${e}`)
+      logs.push(`Raw response: ${aiResponse.substring(0, 500)}`)
+      return NextResponse.json({
+        success: false,
+        logs,
+        error: 'Failed to parse AI fix suggestions'
+      })
+    }
+
+    logs.push('')
+    logs.push(`DIAGNOSIS: ${fixPlan.diagnosis}`)
+    logs.push(`CONFIDENCE: ${fixPlan.confidence}`)
+    logs.push(`FIXES TO APPLY: ${fixPlan.fixes?.length || 0}`)
+    logs.push('')
+
+    // Apply fixes
+    const appliedFixes: string[] = []
+    const failedFixes: string[] = []
+
+    for (const fix of (fixPlan.fixes || [])) {
+      logs.push(`Applying: ${fix.description}`)
+
+      try {
+        switch (fix.type) {
+          case 'command':
+            if (fix.command && !fix.command.includes('rm -rf') && !fix.command.includes('sudo rm')) {
+              execSync(fix.command, {
+                cwd: projectPath,
+                encoding: 'utf-8',
+                timeout: 30000
+              })
+              appliedFixes.push(fix.description)
+              logs.push(`  ✓ Command executed`)
+            } else {
+              logs.push(`  ✗ Skipped unsafe command`)
+              failedFixes.push(`Unsafe: ${fix.description}`)
+            }
+            break
+
+          case 'file_edit':
+            if (fix.path && fix.find && fix.replace !== undefined) {
+              const filePath = path.isAbsolute(fix.path) ? fix.path : path.join(projectPath, fix.path)
+              if (fs.existsSync(filePath)) {
+                let content = fs.readFileSync(filePath, 'utf-8')
+                if (content.includes(fix.find)) {
+                  content = content.replace(fix.find, fix.replace)
+                  fs.writeFileSync(filePath, content)
+                  appliedFixes.push(fix.description)
+                  logs.push(`  ✓ File edited`)
+                } else {
+                  logs.push(`  ✗ Pattern not found in file`)
+                  failedFixes.push(`Pattern not found: ${fix.description}`)
+                }
+              } else {
+                logs.push(`  ✗ File not found: ${filePath}`)
+                failedFixes.push(`File not found: ${fix.description}`)
+              }
+            }
+            break
+
+          case 'file_create':
+            if (fix.path && fix.content !== undefined) {
+              const filePath = path.isAbsolute(fix.path) ? fix.path : path.join(projectPath, fix.path)
+              const dir = path.dirname(filePath)
+              if (!fs.existsSync(dir)) {
+                fs.mkdirSync(dir, { recursive: true })
+              }
+              fs.writeFileSync(filePath, fix.content)
+              appliedFixes.push(fix.description)
+              logs.push(`  ✓ File created`)
+            }
+            break
+
+          case 'file_delete':
+            // Skip file deletions for safety
+            logs.push(`  ✗ Skipped file deletion for safety`)
+            failedFixes.push(`Skipped deletion: ${fix.description}`)
+            break
+
+          default:
+            logs.push(`  ✗ Unknown fix type: ${fix.type}`)
+        }
+      } catch (e: any) {
+        logs.push(`  ✗ Error: ${e.message}`)
+        failedFixes.push(`Error: ${fix.description} - ${e.message}`)
+      }
+    }
+
+    logs.push('')
+    logs.push(`Applied: ${appliedFixes.length} fixes`)
+    logs.push(`Failed: ${failedFixes.length} fixes`)
+
+    // Restart if suggested
+    let restarted = false
+    if (fixPlan.should_restart && appliedFixes.length > 0) {
+      logs.push('')
+      logs.push('Restarting Ralph process...')
+
+      // Reset failed stories to PENDING in progress.txt
+      if (fs.existsSync(progressFile)) {
+        let content = fs.readFileSync(progressFile, 'utf-8')
+        content = content.replace(/: FAIL/g, ': PENDING')
+        content = content.replace(/: STOPPED/g, ': PENDING')
+        fs.writeFileSync(progressFile, content)
+        logs.push('Reset failed stories to PENDING')
+      }
+
+      // Update state file
+      if (fs.existsSync(STATE_FILE)) {
+        try {
+          const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+          state.running = true
+          state.logs = state.logs || []
+          state.logs.push(`> Auto-fix attempt ${attempt} - restarting`)
+
+          // Reset failed story results
+          if (state.storyResults) {
+            for (const [id, status] of Object.entries(state.storyResults)) {
+              if (status === 'FAIL' || status === 'fail' || status === 'STOPPED') {
+                state.storyResults[id] = 'PENDING'
+              }
+            }
+          }
+
+          fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+        } catch {}
+      }
+
+      // Try to start the ralph runner
+      const runnerScript = path.join(projectPath, 'scripts', 'ralph', 'ralph-runner.sh')
+      if (fs.existsSync(runnerScript)) {
+        const proc = spawn('bash', [runnerScript], {
+          cwd: projectPath,
+          detached: true,
+          stdio: 'ignore'
+        })
+        proc.unref()
+        restarted = true
+        logs.push(`Ralph restarted with PID ${proc.pid}`)
+      } else {
+        logs.push('No ralph-runner.sh found - manual restart required')
+      }
+    }
+
+    return NextResponse.json({
+      success: appliedFixes.length > 0,
+      logs,
+      diagnosis: fixPlan.diagnosis,
+      confidence: fixPlan.confidence,
+      appliedFixes,
+      failedFixes,
+      restarted,
+      attempt
+    })
+
+  } catch (error: any) {
+    console.error('Fix process error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/processes/route.ts b/app/api/processes/route.ts
new file mode 100644
index 0000000..ecb28d3
--- /dev/null
+++ b/app/api/processes/route.ts
@@ -0,0 +1,277 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { execSync } from 'child_process'
+import * as fs from 'fs'
+import * as path from 'path'
+
+interface RalphProcess {
+  project: string
+  projectPath: string
+  pid: number
+  cpu: string
+  memory: string
+  status: 'running' | 'idle' | 'completed' | 'failed' | 'paused'
+  currentStory: string
+  startTime: string
+  progress: string
+}
+
+const LOG_FILE = '/tmp/ralph-output.log'
+
+// Parse log file for live story updates (same logic as /api/ralph/status)
+function parseLogForUpdates(state: any) {
+  if (!fs.existsSync(LOG_FILE)) return state
+
+  try {
+    const logContent = fs.readFileSync(LOG_FILE, 'utf-8')
+    const lines = logContent.split('\n').slice(-100)
+
+    for (const line of lines) {
+      const trimmedLine = line.trim()
+      if (!trimmedLine) continue
+
+      // Check for story start
+      const startMatch = trimmedLine.match(/(?:Starting|Working on)\s+(US-\d+)/i)
+      if (startMatch) {
+        const storyId = startMatch[1]
+        state.storyResults = state.storyResults || {}
+        state.storyResults[storyId] = 'running'
+      }
+
+      // Check for story completion
+      const doneMatch = trimmedLine.match(/(?:Completed|Done|DONE)\s+(US-\d+)/i)
+      if (doneMatch) {
+        const storyId = doneMatch[1]
+        state.storyResults = state.storyResults || {}
+        state.storyResults[storyId] = 'pass'
+      }
+
+      // Check for story failure
+      const failMatch = trimmedLine.match(/(?:FAIL|Failed)\s+(US-\d+)/i)
+      if (failMatch) {
+        const storyId = failMatch[1]
+        state.storyResults = state.storyResults || {}
+        state.storyResults[storyId] = 'fail'
+      }
+    }
+
+    // Check if completed
+    if (logContent.includes('RALPH_COMPLETE')) {
+      state.completed = true
+      state.running = false
+    }
+  } catch (e) {
+    // Ignore parse errors
+  }
+
+  return state
+}
+
+export async function GET(request: NextRequest) {
+  try {
+    const ralphProcesses: RalphProcess[] = []
+
+    // Check for ralph state files in /tmp
+    const stateFile = '/tmp/ralph-state.json'
+    if (fs.existsSync(stateFile)) {
+      try {
+        let state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'))
+
+        // Parse log file for live updates
+        state = parseLogForUpdates(state)
+
+        if (state.running || state.completed) {
+          const projectName = path.basename(state.projectPath || '')
+
+          // Count completed stories from updated storyResults
+          const completed = Object.values(state.storyResults || {}).filter((s: any) => s === 'pass' || s === 'DONE').length
+          const total = state.userStories?.length || 0
+
+          // Find current story
+          let currentStory = ''
+          for (const [id, status] of Object.entries(state.storyResults || {})) {
+            if (status === 'running' || status === 'RUNNING') {
+              currentStory = id
+              break
+            }
+          }
+
+          // Determine status with paused check
+          let status: 'running' | 'idle' | 'completed' | 'failed' | 'paused' = 'idle'
+          if (state.completed) status = 'completed'
+          else if (state.paused) status = 'paused'
+          else if (state.running) status = 'running'
+
+          ralphProcesses.push({
+            project: projectName,
+            projectPath: state.projectPath,
+            pid: state.pid || 0,
+            cpu: '0',
+            memory: '0',
+            status,
+            currentStory: currentStory || 'idle',
+            startTime: state.startTime ? new Date(state.startTime).toLocaleTimeString() : '',
+            progress: `${completed}/${total}`
+          })
+        }
+      } catch (e) {
+        // Ignore parse errors
+      }
+    }
+
+    // Scan /root/Projects for any project with scripts/ralph/progress.txt
+    try {
+      const projectsDir = '/root/Projects'
+      const projects = fs.readdirSync(projectsDir)
+
+      for (const proj of projects) {
+        const progressFile = path.join(projectsDir, proj, 'scripts', 'ralph', 'progress.txt')
+        if (fs.existsSync(progressFile)) {
+          // Check if already added from state file
+          if (ralphProcesses.some(p => p.project === proj)) continue
+
+          const content = fs.readFileSync(progressFile, 'utf-8')
+
+          // Parse progress file
+          const lines = content.split('\n')
+          let running = 0, done = 0, pending = 0, failed = 0
+          let currentStory = ''
+
+          for (const line of lines) {
+            if (line.includes(': RUNNING')) {
+              running++
+              const match = line.match(/(US-\d+)/)
+              if (match) currentStory = match[1]
+            } else if (line.includes(': DONE')) {
+              done++
+            } else if (line.includes(': PENDING')) {
+              pending++
+            } else if (line.includes(': FAIL')) {
+              failed++
+            }
+          }
+
+          const total = running + done + pending + failed
+          if (total === 0) continue
+
+          // Determine status
+          let status: 'running' | 'idle' | 'completed' | 'failed' = 'idle'
+          if (running > 0) status = 'running'
+          else if (done === total) status = 'completed'
+          else if (failed > 0 && pending === 0 && running === 0) status = 'failed'
+
+          // Get start time from file
+          const startMatch = content.match(/Started: (.+)/)
+          const startTime = startMatch ? new Date(startMatch[1]).toLocaleTimeString() : ''
+
+          ralphProcesses.push({
+            project: proj,
+            projectPath: path.join(projectsDir, proj),
+            pid: 0,
+            cpu: '0',
+            memory: '0',
+            status,
+            currentStory: currentStory || (status === 'completed' ? 'done' : 'idle'),
+            startTime,
+            progress: `${done}/${total}`
+          })
+        }
+      }
+    } catch (e) {
+      // Ignore scan errors
+    }
+
+    // Check for actual ralph-runner processes running (NOT general node processes)
+    try {
+      // Only look for ralph-runner.sh processes, not all node processes
+      const psOutput = execSync(
+        "ps aux | grep -E 'ralph-runner|ralph-story|claude.*dangerously-skip' | grep -v grep || true",
+        { encoding: 'utf-8', timeout: 5000 }
+      )
+      const lines = psOutput.trim().split('\n').filter(l => l.length > 0)
+
+      for (const line of lines) {
+        const parts = line.split(/\s+/)
+        if (parts.length >= 11) {
+          const pid = parseInt(parts[1])
+          const cpu = parts[2]
+          const mem = parts[3]
+          const procState = parts[7] // Process state (S, R, T, etc.)
+          const cmd = parts.slice(10).join(' ')
+
+          // Try to extract project from command
+          const projectMatch = cmd.match(/\/root\/Projects\/([^\/\s]+)/)
+          const projectName = projectMatch ? projectMatch[1] : null
+
+          if (!projectName) continue
+
+          // Update existing entry or add new
+          const existing = ralphProcesses.find(p =>
+            p.project.toLowerCase() === projectName.toLowerCase()
+          )
+
+          if (existing) {
+            existing.pid = pid
+            existing.cpu = cpu
+            existing.memory = mem
+            // Check if process is stopped/paused (T state)
+            if (procState === 'T') {
+              existing.status = 'paused'
+            } else if (existing.status !== 'completed' && existing.status !== 'failed') {
+              existing.status = 'running'
+            }
+          } else {
+            ralphProcesses.push({
+              project: projectName,
+              projectPath: `/root/Projects/${projectName}`,
+              pid,
+              cpu,
+              memory: mem,
+              status: procState === 'T' ? 'paused' : 'running',
+              currentStory: 'active',
+              startTime: 'now',
+              progress: '?/?'
+            })
+          }
+        }
+      }
+    } catch (e) {
+      // Ignore ps errors
+    }
+
+    // Also check if any tracked process has specific PID and get its real CPU/memory
+    for (const proc of ralphProcesses) {
+      if (proc.pid > 0 && proc.cpu === '0') {
+        try {
+          const pidInfo = execSync(
+            `ps -p ${proc.pid} -o %cpu,%mem,state --no-headers 2>/dev/null || true`,
+            { encoding: 'utf-8', timeout: 3000 }
+          ).trim()
+
+          if (pidInfo) {
+            const [cpu, mem, state] = pidInfo.split(/\s+/)
+            proc.cpu = cpu || '0'
+            proc.memory = mem || '0'
+            if (state === 'T') {
+              proc.status = 'paused'
+            }
+          }
+        } catch {
+          // Process may have ended
+        }
+      }
+    }
+
+    return NextResponse.json({
+      processes: ralphProcesses,
+      timestamp: new Date().toISOString()
+    })
+
+  } catch (error: any) {
+    console.error('Ralph process fetch error:', error)
+    return NextResponse.json({
+      processes: [],
+      error: error.message
+    })
+  }
+}
+
diff --git a/app/api/processes/start/route.ts b/app/api/processes/start/route.ts
new file mode 100644
index 0000000..c928747
--- /dev/null
+++ b/app/api/processes/start/route.ts
@@ -0,0 +1,221 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { spawn, execSync } from 'child_process'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+export async function POST(request: NextRequest) {
+  try {
+    const { projectPath, project } = await request.json()
+
+    if (!projectPath || !project) {
+      return NextResponse.json({ error: 'Missing projectPath or project' }, { status: 400 })
+    }
+
+    // Check if there's a ralph prd.json in the project
+    const prdPath = path.join(projectPath, 'scripts', 'ralph', 'prd.json')
+    if (!fs.existsSync(prdPath)) {
+      return NextResponse.json({
+        error: `No prd.json found at ${prdPath}. Run Ralph Converter first.`
+      }, { status: 400 })
+    }
+
+    // Read the prd.json to get user stories
+    const prdData = JSON.parse(fs.readFileSync(prdPath, 'utf-8'))
+    const branchName = prdData.branchName || `ralph/${project}`
+
+    // Create initial state
+    const storyResults: Record<string, string> = {}
+
+    // Initialize all stories as pending
+    for (const story of prdData.userStories || []) {
+      storyResults[story.id] = 'PENDING'
+    }
+
+    const state = {
+      running: true,
+      projectPath,
+      project,
+      branchName,
+      startTime: new Date().toISOString(),
+      userStories: prdData.userStories || [],
+      storyResults,
+      pid: 0,
+      logs: [
+        `> Starting Ralph for ${project}`,
+        `> Project path: ${projectPath}`,
+        `> Found ${prdData.userStories?.length || 0} user stories`,
+        ''
+      ]
+    }
+
+    fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+
+    // Create or update progress.txt
+    const progressDir = path.join(projectPath, 'scripts', 'ralph')
+    if (!fs.existsSync(progressDir)) {
+      fs.mkdirSync(progressDir, { recursive: true })
+    }
+
+    const progressContent = `# Ralph Progress
+# Project: ${project}
+# Branch: ${branchName}
+# Started: ${new Date().toISOString()}
+
+## Stories
+${state.userStories.map((s: any) => `${s.id}: PENDING - ${s.title}`).join('\n')}
+
+## Log
+`
+    fs.writeFileSync(path.join(progressDir, 'progress.txt'), progressContent)
+
+    // Ensure ralph user can write to the project
+    try {
+      execSync(`chown -R ralph:ralph "${projectPath}"`, { stdio: 'pipe' })
+    } catch {}
+
+    // Create the Ralph runner script with proper loop
+    const scriptPath = '/tmp/ralph-runner.sh'
+    const logPath = '/tmp/ralph-output.log'
+    const progressFilePath = path.join(progressDir, 'progress.txt')
+
+    const script = `#!/bin/bash
+set -e
+
+cd "${projectPath}"
+export HOME="/root"
+export NVM_DIR="/root/.nvm"
+source /root/.nvm/nvm.sh 2>/dev/null || true
+export PATH="/root/.nvm/versions/node/v22.19.0/bin:$PATH"
+
+PROGRESS_FILE="${progressFilePath}"
+LOG_FILE="${logPath}"
+STATE_FILE="${STATE_FILE}"
+
+# Clear log file
+> "$LOG_FILE"
+
+echo "=== RALPHY BOY STARTING ===" >> "$LOG_FILE"
+echo "Project: ${projectPath}" >> "$LOG_FILE"
+echo "Branch: ${branchName}" >> "$LOG_FILE"
+echo "Running as: $(whoami)" >> "$LOG_FILE"
+echo "" >> "$LOG_FILE"
+
+# Try to create git branch
+cd "${projectPath}"
+git checkout -b "${branchName}" 2>/dev/null || git checkout "${branchName}" 2>/dev/null || echo "Git branch setup skipped" >> "$LOG_FILE"
+
+# Loop through each story
+${state.userStories.map((story: any, idx: number) => `
+# Story ${story.id}
+echo "=== Starting ${story.id}: ${story.title.replace(/"/g, '\\"').replace(/`/g, '\\`')} ===" >> "$LOG_FILE"
+sed -i 's/${story.id}: PENDING/${story.id}: RUNNING/' "$PROGRESS_FILE"
+
+# Update state file to show current story
+/root/.nvm/versions/node/v22.19.0/bin/node -e "
+const fs = require('fs');
+const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf-8'));
+state.storyResults['${story.id}'] = 'RUNNING';
+state.currentStoryIndex = ${idx};
+state.logs.push('> Starting ${story.id}: ${story.title.replace(/'/g, "\\'")}');
+fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
+"
+
+# Create story-specific prompt
+cat > /tmp/ralph-story-${story.id}.txt << 'STORYPROMPT'
+You are Ralphy Boy. Implement this ONE story for project ${project}:
+
+STORY: ${story.id} - ${story.title}
+DESCRIPTION: ${story.description}
+
+ACCEPTANCE CRITERIA:
+${story.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}
+
+INSTRUCTIONS:
+1. Implement only this story
+2. Run typecheck: npm run build (or tsc --noEmit)
+3. Commit: git add . && git commit -m "feat(${story.id}): ${story.title.replace(/"/g, '\\"')}"
+4. Announce when done: "Completed ${story.id}"
+
+Start implementation now.
+STORYPROMPT
+
+# Run Claude for this story
+echo "Running Claude for ${story.id}..." >> "$LOG_FILE"
+if /root/.nvm/versions/node/v22.19.0/bin/claude --dangerously-skip-permissions -p "$(cat /tmp/ralph-story-${story.id}.txt)" >> "$LOG_FILE" 2>&1; then
+  echo "Completed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: DONE/' "$PROGRESS_FILE"
+  # Update state
+  /root/.nvm/versions/node/v22.19.0/bin/node -e "
+const fs = require('fs');
+const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf-8'));
+state.storyResults['${story.id}'] = 'DONE';
+state.logs.push('> Completed ${story.id}');
+fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
+  "
+else
+  echo "Failed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: FAIL/' "$PROGRESS_FILE"
+  # Update state
+  /root/.nvm/versions/node/v22.19.0/bin/node -e "
+const fs = require('fs');
+const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf-8'));
+state.storyResults['${story.id}'] = 'FAIL';
+state.logs.push('> Failed ${story.id}');
+fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
+  "
+fi
+echo "" >> "$LOG_FILE"
+`).join('\n')}
+
+# Mark completion
+/root/.nvm/versions/node/v22.19.0/bin/node -e "
+const fs = require('fs');
+const state = JSON.parse(fs.readFileSync('$STATE_FILE', 'utf-8'));
+state.running = false;
+state.completed = true;
+state.logs.push('> All stories processed');
+fs.writeFileSync('$STATE_FILE', JSON.stringify(state, null, 2));
+"
+
+echo "=== RALPH_COMPLETE ===" >> "$LOG_FILE"
+`
+
+    fs.writeFileSync(scriptPath, script)
+    fs.chmodSync(scriptPath, '755')
+
+    // Clear previous log
+    if (fs.existsSync(logPath)) {
+      fs.unlinkSync(logPath)
+    }
+
+    // Spawn the process in background
+    const proc = spawn('bash', [scriptPath], {
+      detached: true,
+      stdio: 'ignore',
+      cwd: projectPath,
+      env: {
+        ...process.env,
+        HOME: '/root',
+        PATH: '/root/.nvm/versions/node/v22.19.0/bin:' + process.env.PATH
+      }
+    })
+    proc.unref()
+
+    // Update state with PID
+    state.pid = proc.pid || 0
+    state.logs.push(`> Spawned process with PID: ${state.pid}`)
+    fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+
+    return NextResponse.json({
+      success: true,
+      message: `Started Ralph for ${project}`,
+      pid: state.pid
+    })
+
+  } catch (error: any) {
+    console.error('Start process error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/processes/stop/route.ts b/app/api/processes/stop/route.ts
new file mode 100644
index 0000000..38fabf7
--- /dev/null
+++ b/app/api/processes/stop/route.ts
@@ -0,0 +1,71 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { execSync } from 'child_process'
+import * as fs from 'fs'
+import * as path from 'path'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+export async function POST(request: NextRequest) {
+  try {
+    const { projectPath, project, pid } = await request.json()
+
+    if (!projectPath || !project) {
+      return NextResponse.json({ error: 'Missing projectPath or project' }, { status: 400 })
+    }
+
+    // Kill the process if PID provided
+    if (pid && pid > 0) {
+      try {
+        // Try to kill the process group first
+        process.kill(-pid, 'SIGTERM')
+      } catch (e) {
+        try {
+          // Fallback to killing just the process
+          process.kill(pid, 'SIGTERM')
+        } catch {}
+      }
+    }
+
+    // Kill any claude processes running for this project
+    try {
+      execSync(`pkill -f "claude.*${project}" || true`, { stdio: 'pipe' })
+    } catch {}
+
+    // Kill any ralph-runner processes
+    try {
+      execSync(`pkill -f "ralph-runner.*${project}" || true`, { stdio: 'pipe' })
+    } catch {}
+
+    // Update state file
+    if (fs.existsSync(STATE_FILE)) {
+      try {
+        const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+        if (state.project === project || state.projectPath === projectPath) {
+          state.running = false
+          state.logs = state.logs || []
+          state.logs.push(`> Stopped by user at ${new Date().toLocaleTimeString()}`)
+          fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+        }
+      } catch {}
+    }
+
+    // Update progress.txt to mark as stopped
+    const progressFile = path.join(projectPath, 'scripts', 'ralph', 'progress.txt')
+    if (fs.existsSync(progressFile)) {
+      let content = fs.readFileSync(progressFile, 'utf-8')
+      // Replace any RUNNING status with STOPPED
+      content = content.replace(/: RUNNING/g, ': STOPPED')
+      content += `\n\nStopped by user at ${new Date().toISOString()}`
+      fs.writeFileSync(progressFile, content)
+    }
+
+    return NextResponse.json({
+      success: true,
+      message: `Stopped Ralph for ${project}`
+    })
+
+  } catch (error: any) {
+    console.error('Stop process error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
new file mode 100644
index 0000000..afa5257
--- /dev/null
+++ b/app/api/projects/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from 'next/server'
+import fs from 'fs'
+import path from 'path'
+
+const PROJECTS_DIR = '/root/Projects'
+
+export async function GET() {
+  try {
+    const entries = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
+
+    const projects = entries
+      .filter(entry => entry.isDirectory())
+      .filter(entry => !entry.name.startsWith('.'))
+      .map(entry => {
+        const projectPath = path.join(PROJECTS_DIR, entry.name)
+        const claudeMdPath = path.join(projectPath, '.claude', 'CLAUDE.md')
+        const hasClaudeMd = fs.existsSync(claudeMdPath) ||
+                          fs.existsSync(path.join(projectPath, 'CLAUDE.md'))
+
+        return {
+          name: entry.name,
+          path: projectPath,
+          hasClaudeMd
+        }
+      })
+      .sort((a, b) => a.name.localeCompare(b.name))
+
+    return NextResponse.json({ projects })
+  } catch (error) {
+    console.error('Failed to list projects:', error)
+    return NextResponse.json({ error: 'Failed to list projects' }, { status: 500 })
+  }
+}
diff --git a/app/api/projects/scan/route.ts b/app/api/projects/scan/route.ts
new file mode 100644
index 0000000..19fff61
--- /dev/null
+++ b/app/api/projects/scan/route.ts
@@ -0,0 +1,404 @@
+import { NextRequest, NextResponse } from 'next/server'
+import fs from 'fs'
+import path from 'path'
+
+// Admin-only: Check for admin header or IP
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
+if (!ADMIN_PASSWORD) {
+  throw new Error('ADMIN_PASSWORD environment variable is required')
+}
+const ADMIN_IPS = ['127.0.0.1', '::1', '45.61.58.125', 'localhost']
+
+function isAdmin(request: NextRequest): boolean {
+  // Check admin header
+  const authHeader = request.headers.get('x-admin-key')
+  if (authHeader === ADMIN_PASSWORD) return true
+
+  // Check for internal/same-origin requests (browser requests from the app itself)
+  const referer = request.headers.get('referer')
+  const host = request.headers.get('host')
+  if (referer && host && referer.includes(host)) return true
+
+  // Check X-Forwarded-For
+  const forwardedFor = request.headers.get('x-forwarded-for')
+  const ip = forwardedFor?.split(',')[0]?.trim() || ''
+  if (ADMIN_IPS.includes(ip)) return true
+
+  // Allow localhost connections
+  const url = new URL(request.url)
+  if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true
+
+  return false
+}
+
+interface GitInfo {
+  isRepo: boolean
+  branch: string | null
+  remoteUrl: string | null
+  lastCommit: string | null
+  lastCommitDate: string | null
+  uncommittedChanges: number
+  hasGitignore: boolean
+}
+
+interface SecurityFeatures {
+  hasAuth: boolean
+  hasEnvExample: boolean
+  hasEnvLocal: boolean
+  hasSecrets: boolean
+  authType: string | null
+  hasHttps: boolean
+  hasCors: boolean
+  hasRateLimiting: boolean
+  hasInputValidation: boolean
+  hasCSRF: boolean
+}
+
+interface DetectedAPI {
+  name: string
+  type: 'ai' | 'payment' | 'storage' | 'auth' | 'email' | 'analytics' | 'other'
+  detected: boolean
+  envVar?: string
+}
+
+interface ProjectScanResult {
+  name: string
+  path: string
+  description: string
+  techStack: string[]
+  dependencies: string[]
+  components: string[]
+  existingPRDs: string[]
+  hasReadme: boolean
+  hasClaudeMd: boolean
+  hasPackageJson: boolean
+  // Enhanced Git Info
+  git: GitInfo
+  // Security Features
+  security: SecurityFeatures
+  // Detected APIs
+  detectedAPIs: DetectedAPI[]
+  recentFiles: string[]
+  suggestedFeatures: string[]
+}
+
+export async function GET(request: NextRequest) {
+  // Check admin access
+  if (!isAdmin(request)) {
+    return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
+  }
+
+  const { searchParams } = new URL(request.url)
+  const projectPath = searchParams.get('path')
+
+  if (!projectPath) {
+    return NextResponse.json({ error: 'Project path required' }, { status: 400 })
+  }
+
+  if (!fs.existsSync(projectPath)) {
+    return NextResponse.json({ error: 'Project not found' }, { status: 404 })
+  }
+
+  try {
+    const result = await scanProject(projectPath)
+    return NextResponse.json(result)
+  } catch (error) {
+    console.error('Failed to scan project:', error)
+    return NextResponse.json({ error: 'Failed to scan project' }, { status: 500 })
+  }
+}
+
+async function scanProject(projectPath: string): Promise<ProjectScanResult> {
+  const name = path.basename(projectPath)
+  const result: ProjectScanResult = {
+    name,
+    path: projectPath,
+    description: '',
+    techStack: [],
+    dependencies: [],
+    components: [],
+    existingPRDs: [],
+    hasReadme: false,
+    hasClaudeMd: false,
+    hasPackageJson: false,
+    git: {
+      isRepo: false,
+      branch: null,
+      remoteUrl: null,
+      lastCommit: null,
+      lastCommitDate: null,
+      uncommittedChanges: 0,
+      hasGitignore: false
+    },
+    security: {
+      hasAuth: false,
+      hasEnvExample: false,
+      hasEnvLocal: false,
+      hasSecrets: false,
+      authType: null,
+      hasHttps: false,
+      hasCors: false,
+      hasRateLimiting: false,
+      hasInputValidation: false,
+      hasCSRF: false
+    },
+    detectedAPIs: [],
+    recentFiles: [],
+    suggestedFeatures: []
+  }
+
+  // Check for package.json
+  const packageJsonPath = path.join(projectPath, 'package.json')
+  if (fs.existsSync(packageJsonPath)) {
+    result.hasPackageJson = true
+    try {
+      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
+      result.description = pkg.description || ''
+
+      // Extract tech stack from dependencies
+      const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
+      if (allDeps.next) result.techStack.push(`Next.js ${allDeps.next.replace('^', '')}`)
+      if (allDeps.react) result.techStack.push(`React ${allDeps.react.replace('^', '')}`)
+      if (allDeps.typescript) result.techStack.push('TypeScript')
+      if (allDeps.tailwindcss) result.techStack.push('Tailwind CSS')
+      if (allDeps.prisma || allDeps['@prisma/client']) result.techStack.push('Prisma')
+      if (allDeps.express) result.techStack.push('Express.js')
+      if (allDeps['@anthropic-ai/sdk']) result.techStack.push('Claude API')
+
+      result.dependencies = Object.keys(allDeps).slice(0, 20)
+    } catch (e) {
+      console.error('Failed to parse package.json:', e)
+    }
+  }
+
+  // Check for README.md
+  const readmePath = path.join(projectPath, 'README.md')
+  if (fs.existsSync(readmePath)) {
+    result.hasReadme = true
+    try {
+      const readme = fs.readFileSync(readmePath, 'utf8')
+      // Extract first paragraph as description if not set
+      if (!result.description) {
+        const lines = readme.split('\n').filter(l => l.trim() && !l.startsWith('#'))
+        result.description = lines[0]?.slice(0, 200) || ''
+      }
+    } catch (e) {
+      console.error('Failed to read README:', e)
+    }
+  }
+
+  // Check for CLAUDE.md
+  const claudeMdPaths = [
+    path.join(projectPath, '.claude', 'CLAUDE.md'),
+    path.join(projectPath, 'CLAUDE.md')
+  ]
+  for (const p of claudeMdPaths) {
+    if (fs.existsSync(p)) {
+      result.hasClaudeMd = true
+      break
+    }
+  }
+
+  // Check for git - Enhanced
+  const gitPath = path.join(projectPath, '.git')
+  result.git.hasGitignore = fs.existsSync(path.join(projectPath, '.gitignore'))
+
+  if (fs.existsSync(gitPath)) {
+    result.git.isRepo = true
+    try {
+      const { exec } = await import('child_process')
+      const { promisify } = await import('util')
+      const execAsync = promisify(exec)
+
+      // Get branch
+      try {
+        const { stdout: branch } = await execAsync(`cd "${projectPath}" && git branch --show-current`)
+        result.git.branch = branch.trim() || 'detached'
+      } catch { result.git.branch = 'unknown' }
+
+      // Get remote URL
+      try {
+        const { stdout: remote } = await execAsync(`cd "${projectPath}" && git remote get-url origin 2>/dev/null`)
+        result.git.remoteUrl = remote.trim() || null
+      } catch { /* no remote */ }
+
+      // Get last commit
+      try {
+        const { stdout: commit } = await execAsync(`cd "${projectPath}" && git log -1 --format="%s" 2>/dev/null`)
+        result.git.lastCommit = commit.trim() || null
+      } catch { /* no commits */ }
+
+      // Get last commit date
+      try {
+        const { stdout: date } = await execAsync(`cd "${projectPath}" && git log -1 --format="%ci" 2>/dev/null`)
+        result.git.lastCommitDate = date.trim() || null
+      } catch { /* no commits */ }
+
+      // Get uncommitted changes count
+      try {
+        const { stdout: status } = await execAsync(`cd "${projectPath}" && git status --porcelain 2>/dev/null`)
+        result.git.uncommittedChanges = status.trim().split('\n').filter(Boolean).length
+      } catch { /* ignore */ }
+    } catch (e) {
+      console.error('Git scan error:', e)
+    }
+  }
+
+  // Check security features
+  result.security.hasEnvExample = fs.existsSync(path.join(projectPath, '.env.example'))
+  result.security.hasEnvLocal = fs.existsSync(path.join(projectPath, '.env.local')) || fs.existsSync(path.join(projectPath, '.env'))
+
+  // Scan for auth patterns in code
+  try {
+    const { exec } = await import('child_process')
+    const { promisify } = await import('util')
+    const execAsync = promisify(exec)
+
+    // Check for auth libraries
+    if (result.dependencies.includes('next-auth') || result.dependencies.includes('@auth/core')) {
+      result.security.hasAuth = true
+      result.security.authType = 'NextAuth'
+    } else if (result.dependencies.includes('passport')) {
+      result.security.hasAuth = true
+      result.security.authType = 'Passport'
+    } else if (result.dependencies.includes('jsonwebtoken') || result.dependencies.includes('jose')) {
+      result.security.hasAuth = true
+      result.security.authType = 'JWT'
+    }
+
+    // Check for CORS
+    if (result.dependencies.includes('cors') || result.dependencies.includes('@fastify/cors')) {
+      result.security.hasCors = true
+    }
+
+    // Check for rate limiting
+    if (result.dependencies.includes('express-rate-limit') || result.dependencies.includes('rate-limiter-flexible')) {
+      result.security.hasRateLimiting = true
+    }
+
+    // Check for validation
+    if (result.dependencies.includes('zod') || result.dependencies.includes('joi') || result.dependencies.includes('yup')) {
+      result.security.hasInputValidation = true
+    }
+
+    // Check for CSRF
+    if (result.dependencies.includes('csurf') || result.dependencies.includes('csrf')) {
+      result.security.hasCSRF = true
+    }
+
+    // Check for secrets/credentials in common files
+    try {
+      const { stdout: secretsCheck } = await execAsync(
+        `grep -r "password\\|secret\\|api_key\\|apikey" "${projectPath}"/*.ts "${projectPath}"/*.js "${projectPath}"/lib/*.ts 2>/dev/null | head -5`
+      )
+      result.security.hasSecrets = secretsCheck.trim().length > 0
+    } catch { /* no matches */ }
+  } catch (e) {
+    console.error('Security scan error:', e)
+  }
+
+  // Detect APIs from dependencies and env files
+  const apiDetections: DetectedAPI[] = [
+    { name: 'Claude/Anthropic', type: 'ai', detected: result.dependencies.includes('@anthropic-ai/sdk'), envVar: 'ANTHROPIC_API_KEY' },
+    { name: 'OpenAI', type: 'ai', detected: result.dependencies.includes('openai'), envVar: 'OPENAI_API_KEY' },
+    { name: 'Google AI', type: 'ai', detected: result.dependencies.includes('@google/generative-ai'), envVar: 'GOOGLE_AI_KEY' },
+    { name: 'Stripe', type: 'payment', detected: result.dependencies.includes('stripe'), envVar: 'STRIPE_SECRET_KEY' },
+    { name: 'PayPal', type: 'payment', detected: result.dependencies.includes('@paypal/checkout-server-sdk'), envVar: 'PAYPAL_CLIENT_ID' },
+    { name: 'AWS S3', type: 'storage', detected: result.dependencies.includes('@aws-sdk/client-s3'), envVar: 'AWS_ACCESS_KEY_ID' },
+    { name: 'Cloudinary', type: 'storage', detected: result.dependencies.includes('cloudinary'), envVar: 'CLOUDINARY_URL' },
+    { name: 'Firebase', type: 'auth', detected: result.dependencies.includes('firebase') || result.dependencies.includes('firebase-admin'), envVar: 'FIREBASE_API_KEY' },
+    { name: 'Auth0', type: 'auth', detected: result.dependencies.includes('@auth0/nextjs-auth0'), envVar: 'AUTH0_SECRET' },
+    { name: 'SendGrid', type: 'email', detected: result.dependencies.includes('@sendgrid/mail'), envVar: 'SENDGRID_API_KEY' },
+    { name: 'Resend', type: 'email', detected: result.dependencies.includes('resend'), envVar: 'RESEND_API_KEY' },
+    { name: 'Twilio', type: 'email', detected: result.dependencies.includes('twilio'), envVar: 'TWILIO_ACCOUNT_SID' },
+    { name: 'Shopify', type: 'other', detected: result.dependencies.includes('@shopify/shopify-api'), envVar: 'SHOPIFY_ACCESS_TOKEN' },
+    { name: 'Slack', type: 'other', detected: result.dependencies.includes('@slack/web-api'), envVar: 'SLACK_BOT_TOKEN' },
+    { name: 'Google Analytics', type: 'analytics', detected: result.dependencies.includes('@google-analytics/data'), envVar: 'GA_MEASUREMENT_ID' },
+    { name: 'Mixpanel', type: 'analytics', detected: result.dependencies.includes('mixpanel'), envVar: 'MIXPANEL_TOKEN' },
+    { name: 'Sentry', type: 'analytics', detected: result.dependencies.includes('@sentry/nextjs'), envVar: 'SENTRY_DSN' },
+    { name: 'Prisma', type: 'storage', detected: result.dependencies.includes('@prisma/client'), envVar: 'DATABASE_URL' },
+    { name: 'ElevenLabs', type: 'ai', detected: result.dependencies.includes('elevenlabs'), envVar: 'ELEVENLABS_API_KEY' },
+    { name: 'Zendesk', type: 'other', detected: result.dependencies.includes('zendesk-api'), envVar: 'ZENDESK_API_TOKEN' },
+  ]
+
+  result.detectedAPIs = apiDetections.filter(api => api.detected)
+
+  // Scan components directory
+  const componentsDirs = ['components', 'src/components', 'app/components']
+  for (const dir of componentsDirs) {
+    const dirPath = path.join(projectPath, dir)
+    if (fs.existsSync(dirPath)) {
+      try {
+        const files = fs.readdirSync(dirPath, { withFileTypes: true })
+        for (const file of files) {
+          if (file.isFile() && (file.name.endsWith('.tsx') || file.name.endsWith('.jsx'))) {
+            result.components.push(file.name)
+          }
+        }
+      } catch (e) {
+        console.error('Failed to scan components:', e)
+      }
+      break
+    }
+  }
+
+  // Scan for existing PRDs
+  const tasksDir = path.join(projectPath, 'tasks')
+  if (fs.existsSync(tasksDir)) {
+    try {
+      const files = fs.readdirSync(tasksDir)
+      result.existingPRDs = files.filter(f => f.startsWith('prd-') && f.endsWith('.md'))
+    } catch (e) {
+      console.error('Failed to scan tasks:', e)
+    }
+  }
+
+  // Get recently modified files
+  try {
+    const { exec } = await import('child_process')
+    const { promisify } = await import('util')
+    const execAsync = promisify(exec)
+    const { stdout } = await execAsync(
+      `find "${projectPath}" -type f -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" 2>/dev/null | head -20 | xargs ls -t 2>/dev/null | head -10`
+    )
+    result.recentFiles = stdout.trim().split('\n').filter(Boolean).map(f => path.relative(projectPath, f))
+  } catch (e) {
+    // Ignore errors
+  }
+
+  // Generate suggested features based on project analysis
+  result.suggestedFeatures = generateFeatureSuggestions(result)
+
+  return result
+}
+
+function generateFeatureSuggestions(project: ProjectScanResult): string[] {
+  const suggestions: string[] = []
+
+  if (!project.hasReadme) {
+    suggestions.push('Add comprehensive README documentation')
+  }
+  if (!project.hasClaudeMd) {
+    suggestions.push('Create CLAUDE.md for AI-assisted development')
+  }
+  if (project.techStack.includes('Next.js') && !project.components.some(c => c.includes('Loading'))) {
+    suggestions.push('Add loading states and skeletons')
+  }
+  if (project.techStack.includes('Next.js') && !project.components.some(c => c.includes('Error'))) {
+    suggestions.push('Add error boundary components')
+  }
+  if (project.components.length > 10) {
+    suggestions.push('Refactor and organize component structure')
+  }
+  if (project.techStack.includes('Tailwind CSS')) {
+    suggestions.push('Add dark mode support')
+  }
+  if (project.dependencies.includes('next-auth') || project.dependencies.includes('auth')) {
+    suggestions.push('Enhance authentication security')
+  }
+  if (!project.dependencies.includes('jest') && !project.dependencies.includes('vitest')) {
+    suggestions.push('Add unit testing framework')
+  }
+
+  return suggestions.slice(0, 5)
+}
diff --git a/app/api/quickmode/route.ts b/app/api/quickmode/route.ts
new file mode 100644
index 0000000..c977e65
--- /dev/null
+++ b/app/api/quickmode/route.ts
@@ -0,0 +1,67 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { Pool } from 'pg'
+
+const DATABASE_URL = process.env.DATABASE_URL
+if (!DATABASE_URL) {
+  throw new Error('DATABASE_URL environment variable is required')
+}
+
+const pool = new Pool({
+  connectionString: DATABASE_URL
+})
+
+// GET - Retrieve last 10 quick mode entries
+export async function GET() {
+  try {
+    const result = await pool.query(
+      `SELECT id, project_name, project_path, entry_text, created_at
+       FROM quick_mode_entries
+       ORDER BY created_at DESC
+       LIMIT 10`
+    )
+    return NextResponse.json({ entries: result.rows })
+  } catch (error) {
+    console.error('Failed to fetch quick mode entries:', error)
+    return NextResponse.json({ error: 'Failed to fetch entries' }, { status: 500 })
+  }
+}
+
+// POST - Save a new quick mode entry
+export async function POST(request: NextRequest) {
+  try {
+    let body: { projectName?: string; projectPath?: string; entryText?: string }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const { projectName, projectPath, entryText } = body
+
+    if (!projectName || !projectPath || !entryText) {
+      return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
+    }
+
+    // Insert new entry
+    const result = await pool.query(
+      `INSERT INTO quick_mode_entries (project_name, project_path, entry_text)
+       VALUES ($1, $2, $3)
+       RETURNING id, project_name, project_path, entry_text, created_at`,
+      [projectName, projectPath, entryText]
+    )
+
+    // Clean up old entries - keep only last 10
+    await pool.query(
+      `DELETE FROM quick_mode_entries
+       WHERE id NOT IN (
+         SELECT id FROM quick_mode_entries
+         ORDER BY created_at DESC
+         LIMIT 10
+       )`
+    )
+
+    return NextResponse.json({ entry: result.rows[0], success: true })
+  } catch (error) {
+    console.error('Failed to save quick mode entry:', error)
+    return NextResponse.json({ error: 'Failed to save entry' }, { status: 500 })
+  }
+}
diff --git a/app/api/ralph/continue/route.ts b/app/api/ralph/continue/route.ts
new file mode 100644
index 0000000..44e869b
--- /dev/null
+++ b/app/api/ralph/continue/route.ts
@@ -0,0 +1,153 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { spawn } from 'child_process'
+import * as fs from 'fs'
+import * as path from 'path'
+
+// Continue Ralph from where it left off - only runs PENDING stories
+
+export async function POST(request: NextRequest) {
+  try {
+    const { projectPath } = await request.json()
+
+    if (!projectPath) {
+      return NextResponse.json({ error: 'Missing projectPath' }, { status: 400 })
+    }
+
+    const ralphDir = path.join(projectPath, 'scripts', 'ralph')
+    const prdPath = path.join(ralphDir, 'prd.json')
+    const progressPath = path.join(ralphDir, 'progress.txt')
+
+    // Check if PRD exists
+    if (!fs.existsSync(prdPath)) {
+      return NextResponse.json({ error: 'No prd.json found. Run Start first.' }, { status: 400 })
+    }
+
+    // Check if progress exists
+    if (!fs.existsSync(progressPath)) {
+      return NextResponse.json({ error: 'No progress.txt found. Run Start first.' }, { status: 400 })
+    }
+
+    // Read PRD
+    const ralphData = JSON.parse(fs.readFileSync(prdPath, 'utf-8'))
+
+    // Read progress to find PENDING stories
+    const progressContent = fs.readFileSync(progressPath, 'utf-8')
+    const pendingStories: string[] = []
+    const lines = progressContent.split('\n')
+
+    for (const line of lines) {
+      const match = line.match(/^(US-\d+): (PENDING|RUNNING)/)
+      if (match) {
+        pendingStories.push(match[1])
+      }
+    }
+
+    if (pendingStories.length === 0) {
+      return NextResponse.json({
+        success: true,
+        message: 'All stories already completed!',
+        completed: true
+      })
+    }
+
+    // Filter userStories to only include pending ones
+    const storiesToRun = ralphData.userStories.filter((s: any) => pendingStories.includes(s.id))
+
+    const logPath = '/tmp/ralph-output.log'
+    const scriptPath = '/tmp/ralph-continue.sh'
+
+    // Ensure ralph user can write to the project and log file
+    const { execSync } = require('child_process')
+    try {
+      execSync(`chown -R ralph:ralph "${projectPath}"`, { stdio: 'pipe' })
+      execSync(`touch ${logPath} && chmod 666 ${logPath}`, { stdio: 'pipe' })
+    } catch {}
+
+    // Create script that only runs pending stories
+    const script = `#!/bin/bash
+set -e
+
+cd "${projectPath}"
+export HOME="/home/ralph"
+export NVM_DIR="/home/ralph/.nvm"
+export PATH="/home/ralph/.nvm/versions/node/v22.19.0/bin:$PATH"
+export ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY || ''}"
+
+PROGRESS_FILE="${progressPath}"
+LOG_FILE="${logPath}"
+
+# Append to log file (don't clear)
+echo "" >> "$LOG_FILE"
+echo "=== RALPH CONTINUING ===" >> "$LOG_FILE"
+echo "Project: ${projectPath}" >> "$LOG_FILE"
+echo "Pending stories: ${pendingStories.join(', ')}" >> "$LOG_FILE"
+echo "Running as: $(whoami)" >> "$LOG_FILE"
+echo "" >> "$LOG_FILE"
+
+${storiesToRun.map((story: any) => `
+# Story ${story.id}
+echo "=== Starting ${story.id}: ${story.title.replace(/"/g, '\\"')} ===" >> "$LOG_FILE"
+sed -i 's/${story.id}: PENDING/${story.id}: RUNNING/' "$PROGRESS_FILE"
+sed -i 's/${story.id}: RUNNING/${story.id}: RUNNING/' "$PROGRESS_FILE"
+
+cat > /tmp/ralph-story-${story.id}.txt << 'STORYPROMPT'
+You are Ralphy Boy. Implement this ONE story for project ${ralphData.project}:
+
+STORY: ${story.id} - ${story.title}
+DESCRIPTION: ${story.description}
+
+ACCEPTANCE CRITERIA:
+${story.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}
+
+INSTRUCTIONS:
+1. Implement only this story
+2. Run typecheck: npm run build (or tsc --noEmit)
+3. Commit: git add . && git commit -m "feat(${story.id}): ${story.title.replace(/"/g, '\\"')}"
+4. Announce when done: "Completed ${story.id}"
+
+Start implementation now.
+STORYPROMPT
+
+echo "Running Gemini for ${story.id}..." >> "$LOG_FILE"
+if /home/ralph/.nvm/versions/node/v22.19.0/bin/node ${process.cwd()}/scripts/ralph-llm-runner.js --prompt-file /tmp/ralph-story-${story.id}.txt --project "${projectPath}" >> "$LOG_FILE" 2>&1; then
+  echo "Completed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: DONE/' "$PROGRESS_FILE"
+else
+  echo "Failed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: FAIL/' "$PROGRESS_FILE"
+fi
+echo "" >> "$LOG_FILE"
+`).join('\n')}
+
+echo "=== RALPH_COMPLETE ===" >> "$LOG_FILE"
+`
+
+    fs.writeFileSync(scriptPath, script)
+    fs.chmodSync(scriptPath, '755')
+
+    // Spawn the process as ralph user
+    const proc = spawn('sudo', ['-u', 'ralph', 'bash', scriptPath], {
+      detached: true,
+      stdio: 'ignore',
+      cwd: projectPath,
+      env: {
+        ...process.env,
+        HOME: '/home/ralph',
+        PATH: '/home/ralph/.nvm/versions/node/v22.19.0/bin:' + process.env.PATH,
+        ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || ''
+      }
+    })
+    proc.unref()
+
+    return NextResponse.json({
+      success: true,
+      message: `Continuing Ralph with ${storiesToRun.length} pending stories`,
+      pendingStories,
+      pid: proc.pid
+    })
+
+  } catch (error: any) {
+    console.error('Ralph continue error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/ralph/control/route.ts b/app/api/ralph/control/route.ts
new file mode 100644
index 0000000..67094d8
--- /dev/null
+++ b/app/api/ralph/control/route.ts
@@ -0,0 +1,194 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { execSync, exec } from 'child_process'
+import * as fs from 'fs'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+interface RalphState {
+  running: boolean
+  paused?: boolean
+  pid?: number
+  projectPath?: string
+  logs: string[]
+  [key: string]: any
+}
+
+function getState(): RalphState {
+  if (fs.existsSync(STATE_FILE)) {
+    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+  }
+  return { running: false, logs: [] }
+}
+
+function saveState(state: RalphState) {
+  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+}
+
+function findProcessByProject(projectName: string): number | null {
+  try {
+    // Find ralph/claude processes for specific project
+    const output = execSync(
+      `ps aux | grep -E 'ralph-runner|claude.*dangerously' | grep '${projectName}' | grep -v grep | awk '{print $2}' | head -1`,
+      { encoding: 'utf-8', timeout: 5000 }
+    ).trim()
+    return output ? parseInt(output) : null
+  } catch {
+    return null
+  }
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    const body = await request.json()
+    const { action, project, pid } = body
+
+    if (!action || !['stop', 'pause', 'resume'].includes(action)) {
+      return NextResponse.json(
+        { error: 'Invalid action. Must be stop, pause, or resume' },
+        { status: 400 }
+      )
+    }
+
+    const state = getState()
+    let targetPid = pid
+
+    // If no PID provided, try to find it from project name or state
+    if (!targetPid && project) {
+      targetPid = findProcessByProject(project)
+    }
+    if (!targetPid && state.pid) {
+      targetPid = state.pid
+    }
+
+    if (!targetPid) {
+      return NextResponse.json(
+        { error: 'No process found to control' },
+        { status: 404 }
+      )
+    }
+
+    switch (action) {
+      case 'stop': {
+        try {
+          const killed: string[] = []
+
+          // Try to kill process group first (negative PID) with SIGKILL
+          try {
+            process.kill(-targetPid, 'SIGKILL')
+            killed.push(`pid ${targetPid}`)
+          } catch {
+            try {
+              process.kill(targetPid, 'SIGKILL')
+              killed.push(`pid ${targetPid}`)
+            } catch {}
+          }
+
+          // Kill ralph-runner script
+          try {
+            execSync('pkill -9 -f "ralph-runner" || true', { stdio: 'pipe' })
+            killed.push('ralph-runner')
+          } catch {}
+
+          // Kill claude processes with dangerously-skip-permissions
+          try {
+            execSync('pkill -9 -f "claude.*dangerously-skip-permissions" || true', { stdio: 'pipe' })
+            killed.push('claude')
+          } catch {}
+
+          // Kill ralph-story processes
+          try {
+            execSync('pkill -9 -f "ralph-story" || true', { stdio: 'pipe' })
+            killed.push('ralph-story')
+          } catch {}
+
+          // Kill sudo ralph processes
+          try {
+            execSync('pkill -9 -f "sudo.*ralph" || true', { stdio: 'pipe' })
+            killed.push('sudo ralph')
+          } catch {}
+
+          state.running = false
+          state.paused = false
+          state.pid = undefined
+          state.logs.push(`> STOPPED by user (killed: ${killed.join(', ')})`)
+          saveState(state)
+
+          return NextResponse.json({
+            success: true,
+            message: `Stopped. Killed: ${killed.join(', ')}`,
+            action: 'stop'
+          })
+        } catch (e: any) {
+          return NextResponse.json(
+            { error: `Failed to stop: ${e.message}` },
+            { status: 500 }
+          )
+        }
+      }
+
+      case 'pause': {
+        try {
+          // Send SIGSTOP to pause the process
+          process.kill(targetPid, 'SIGSTOP')
+
+          // Also pause child processes
+          try {
+            execSync(`pkill -STOP -P ${targetPid} || true`, { stdio: 'pipe' })
+          } catch {}
+
+          state.paused = true
+          state.logs.push(`> Paused process ${targetPid}`)
+          saveState(state)
+
+          return NextResponse.json({
+            success: true,
+            message: `Paused process ${targetPid}`,
+            action: 'pause'
+          })
+        } catch (e: any) {
+          return NextResponse.json(
+            { error: `Failed to pause: ${e.message}` },
+            { status: 500 }
+          )
+        }
+      }
+
+      case 'resume': {
+        try {
+          // Send SIGCONT to resume the process
+          process.kill(targetPid, 'SIGCONT')
+
+          // Also resume child processes
+          try {
+            execSync(`pkill -CONT -P ${targetPid} || true`, { stdio: 'pipe' })
+          } catch {}
+
+          state.paused = false
+          state.logs.push(`> Resumed process ${targetPid}`)
+          saveState(state)
+
+          return NextResponse.json({
+            success: true,
+            message: `Resumed process ${targetPid}`,
+            action: 'resume'
+          })
+        } catch (e: any) {
+          return NextResponse.json(
+            { error: `Failed to resume: ${e.message}` },
+            { status: 500 }
+          )
+        }
+      }
+
+      default:
+        return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
+    }
+
+  } catch (error: any) {
+    console.error('Ralph control error:', error)
+    return NextResponse.json(
+      { error: error.message },
+      { status: 500 }
+    )
+  }
+}
diff --git a/app/api/ralph/convert/route.ts b/app/api/ralph/convert/route.ts
new file mode 100644
index 0000000..9a2ff2f
--- /dev/null
+++ b/app/api/ralph/convert/route.ts
@@ -0,0 +1,241 @@
+import { NextRequest, NextResponse } from 'next/server'
+import * as fs from 'fs'
+import * as path from 'path'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.3, maxOutputTokens: 4000 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+interface UserStory {
+  id: string
+  title: string
+  description: string
+  acceptanceCriteria: string[]
+  priority: number
+  passes: boolean
+  notes: string
+}
+
+interface RalphData {
+  project: string
+  branchName: string
+  description: string
+  userStories: UserStory[]
+}
+
+interface ValidationResult {
+  valid: boolean
+  issues: string[]
+}
+
+// Validate the converted prd.json follows Ralph skill rules
+function validateRalphData(data: RalphData): ValidationResult {
+  const issues: string[] = []
+
+  // Check each story
+  data.userStories.forEach((story, idx) => {
+    // Check story sizing (description should be 2-3 sentences)
+    const sentences = story.description.split(/[.!?]+/).filter(s => s.trim()).length
+    if (sentences > 4) {
+      issues.push(`${story.id}: Description too long (${sentences} sentences). Should be 2-3 sentences max.`)
+    }
+
+    // Check for "Typecheck passes" criterion
+    const hasTypecheck = story.acceptanceCriteria.some(c =>
+      c.toLowerCase().includes('typecheck') || c.toLowerCase().includes('type check')
+    )
+    if (!hasTypecheck) {
+      issues.push(`${story.id}: Missing "Typecheck passes" acceptance criterion`)
+    }
+
+    // Check UI stories have browser verification
+    const isUIStory = story.title.toLowerCase().includes('ui') ||
+      story.title.toLowerCase().includes('component') ||
+      story.title.toLowerCase().includes('display') ||
+      story.title.toLowerCase().includes('page') ||
+      story.description.toLowerCase().includes('user interface')
+    const hasBrowserCheck = story.acceptanceCriteria.some(c =>
+      c.toLowerCase().includes('browser') || c.toLowerCase().includes('verify in browser')
+    )
+    if (isUIStory && !hasBrowserCheck) {
+      issues.push(`${story.id}: UI story missing "Verify in browser" criterion`)
+    }
+
+    // Check acceptance criteria are specific (not vague)
+    story.acceptanceCriteria.forEach(criterion => {
+      const vagueTerms = ['works correctly', 'functions properly', 'is good', 'is done', 'is complete']
+      if (vagueTerms.some(term => criterion.toLowerCase().includes(term))) {
+        issues.push(`${story.id}: Vague criterion "${criterion}" - must be specific and verifiable`)
+      }
+    })
+  })
+
+  // Check ordering (schema/DB before backend before UI)
+  const storyTypes = data.userStories.map(s => {
+    if (s.title.toLowerCase().includes('schema') || s.title.toLowerCase().includes('migration') || s.title.toLowerCase().includes('database')) return 'db'
+    if (s.title.toLowerCase().includes('api') || s.title.toLowerCase().includes('backend') || s.title.toLowerCase().includes('server')) return 'backend'
+    if (s.title.toLowerCase().includes('ui') || s.title.toLowerCase().includes('component') || s.title.toLowerCase().includes('page')) return 'ui'
+    return 'other'
+  })
+
+  let lastType = 'db'
+  const typeOrder = { db: 0, backend: 1, other: 2, ui: 3 }
+  storyTypes.forEach((type, idx) => {
+    if (typeOrder[type] < typeOrder[lastType as keyof typeof typeOrder]) {
+      issues.push(`Story ordering issue: ${data.userStories[idx].id} (${type}) should come before earlier ${lastType} stories`)
+    }
+    lastType = type
+  })
+
+  return {
+    valid: issues.length === 0,
+    issues
+  }
+}
+
+// Archive existing prd.json if it exists
+function archiveExistingPrd(projectPath: string): string | null {
+  const ralphDir = path.join(projectPath, 'scripts', 'ralph')
+  const prdPath = path.join(ralphDir, 'prd.json')
+  const progressPath = path.join(ralphDir, 'progress.txt')
+
+  if (fs.existsSync(prdPath)) {
+    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
+    const archiveDir = path.join(ralphDir, 'archive', timestamp)
+    fs.mkdirSync(archiveDir, { recursive: true })
+
+    fs.copyFileSync(prdPath, path.join(archiveDir, 'prd.json'))
+    if (fs.existsSync(progressPath)) {
+      fs.copyFileSync(progressPath, path.join(archiveDir, 'progress.txt'))
+    }
+
+    return archiveDir
+  }
+  return null
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let body: { prdMarkdown?: string; projectName?: string; projectPath?: string }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const { prdMarkdown, projectName, projectPath } = body
+
+    if (!prdMarkdown) {
+      return NextResponse.json({ error: 'PRD markdown required' }, { status: 400 })
+    }
+
+    // Archive existing prd.json if present
+    let archivedTo: string | null = null
+    if (projectPath) {
+      archivedTo = archiveExistingPrd(projectPath)
+    }
+
+    const prompt = `You are the Ralph PRD Converter. Convert this PRD to prd.json format for autonomous agent execution.
+
+PRD Content:
+${prdMarkdown}
+
+Project Name: ${projectName || 'Unknown'}
+
+## Output Format
+Generate a JSON object with this exact structure:
+{
+  "project": "[Project Name]",
+  "branchName": "ralph/[feature-name-kebab-case]",
+  "description": "[Feature description from PRD]",
+  "userStories": [
+    {
+      "id": "US-001",
+      "title": "[Concise title]",
+      "description": "As a [user], I want [feature] so that [benefit]",
+      "acceptanceCriteria": [
+        "Specific verifiable criterion 1",
+        "Specific verifiable criterion 2",
+        "Typecheck passes"
+      ],
+      "priority": 1,
+      "passes": false,
+      "notes": ""
+    }
+  ]
+}
+
+## CRITICAL SIZING RULE
+**Each story must be completable in ONE Ralph iteration (one context window).**
+- Story description: 2-3 sentences MAXIMUM
+- Right-sized: "Add status column to users table", "Create UserCard component", "Add API endpoint for stats"
+- TOO BIG: "Build entire dashboard", "Implement full auth system" - SPLIT THESE!
+
+## ORDERING RULE
+Stories execute sequentially by priority. Order by dependency:
+1. Database/schema changes (migrations)
+2. Backend logic (API endpoints, server actions)
+3. UI components (that use the backend)
+No story can depend on a later story!
+
+## ACCEPTANCE CRITERIA RULES
+- MUST be verifiable (specific, checkable)
+- EVERY story MUST include: "Typecheck passes"
+- UI stories MUST include: "Verify in browser using dev-browser skill"
+- BAD: "works correctly", "functions properly" (too vague)
+- GOOD: "Returns 200 status with user object", "Button is disabled when loading"
+
+Return ONLY the JSON object. No markdown formatting, no explanation.`
+
+    const text = await callGemini(prompt)
+
+    // Parse JSON from response
+    let ralphData: RalphData
+    try {
+      // Try to extract JSON if wrapped in markdown
+      const jsonMatch = text.match(/\{[\s\S]*\}/)
+      if (jsonMatch) {
+        ralphData = JSON.parse(jsonMatch[0])
+      } else {
+        ralphData = JSON.parse(text)
+      }
+    } catch {
+      console.error('Failed to parse Ralph JSON:', text)
+      return NextResponse.json({ error: 'Failed to parse Ralph data' }, { status: 500 })
+    }
+
+    // Validate the converted data against Ralph skill rules
+    const validation = validateRalphData(ralphData)
+
+    return NextResponse.json({
+      ralphData,
+      success: true,
+      validation,
+      archivedTo
+    })
+  } catch (error) {
+    console.error('Failed to convert to Ralph format:', error)
+    return NextResponse.json({ error: 'Failed to convert' }, { status: 500 })
+  }
+}
diff --git a/app/api/ralph/run/route.ts b/app/api/ralph/run/route.ts
new file mode 100644
index 0000000..1ca6483
--- /dev/null
+++ b/app/api/ralph/run/route.ts
@@ -0,0 +1,308 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { spawn, ChildProcess } from 'child_process'
+import * as fs from 'fs'
+import * as path from 'path'
+
+// State file path for cross-request persistence
+const STATE_FILE = '/tmp/ralph-state.json'
+
+interface RalphState {
+  running: boolean
+  projectPath: string
+  branchName: string
+  startTime: number
+  currentStoryIndex: number
+  logs: string[]
+  storyResults: Record<string, 'pending' | 'running' | 'pass' | 'fail'>
+  storyTimes: Record<string, number>
+  storyNotes: Record<string, string>
+  userStories: any[]
+  completed: boolean
+  pid?: number
+}
+
+function getState(): RalphState {
+  if (fs.existsSync(STATE_FILE)) {
+    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+  }
+  return {
+    running: false,
+    projectPath: '',
+    branchName: '',
+    startTime: 0,
+    currentStoryIndex: 0,
+    logs: [],
+    storyResults: {},
+    storyTimes: {},
+    storyNotes: {},
+    userStories: [],
+    completed: false
+  }
+}
+
+function saveState(state: RalphState) {
+  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+}
+
+function addLog(state: RalphState, message: string) {
+  state.logs.push(message)
+  if (state.logs.length > 500) {
+    state.logs = state.logs.slice(-500)
+  }
+  saveState(state)
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    const { ralphData, projectPath } = await request.json()
+
+    if (!ralphData || !projectPath) {
+      return NextResponse.json({ error: 'Missing ralphData or projectPath' }, { status: 400 })
+    }
+
+    // Initialize state
+    const state: RalphState = {
+      running: true,
+      projectPath,
+      branchName: ralphData.branchName,
+      startTime: Date.now(),
+      currentStoryIndex: 0,
+      logs: [],
+      storyResults: {},
+      storyTimes: {},
+      storyNotes: {},
+      userStories: ralphData.userStories,
+      completed: false
+    }
+
+    // Initialize story results
+    ralphData.userStories.forEach((story: any) => {
+      state.storyResults[story.id] = 'pending'
+    })
+
+    addLog(state, `> Ralphy Boy starting...`)
+    addLog(state, `> Project: ${projectPath}`)
+    addLog(state, `> Branch: ${ralphData.branchName}`)
+    addLog(state, `> Stories: ${ralphData.userStories.length}`)
+    addLog(state, '')
+
+    // Create scripts/ralph directory if needed
+    const ralphDir = path.join(projectPath, 'scripts', 'ralph')
+    if (!fs.existsSync(ralphDir)) {
+      fs.mkdirSync(ralphDir, { recursive: true })
+    }
+
+    // Save prd.json
+    const prdPath = path.join(ralphDir, 'prd.json')
+    fs.writeFileSync(prdPath, JSON.stringify(ralphData, null, 2))
+    addLog(state, `> Saved prd.json to ${prdPath}`)
+
+    // Create progress.txt
+    const progressPath = path.join(ralphDir, 'progress.txt')
+    const progressContent = generateProgressFile(ralphData)
+    fs.writeFileSync(progressPath, progressContent)
+    addLog(state, `> Created progress.txt`)
+
+    // Ensure git repo exists (auto-init if needed)
+    const { execSync } = require('child_process')
+    try {
+      execSync(`git rev-parse --git-dir`, { cwd: projectPath, stdio: 'pipe' })
+      addLog(state, `> Git repo detected`)
+    } catch {
+      addLog(state, `> No git repo found, initializing...`)
+      try {
+        // Add to safe directories first
+        execSync(`git config --global --add safe.directory "${projectPath}"`, { stdio: 'pipe' })
+        // Initialize git repo
+        execSync(`git init`, { cwd: projectPath, stdio: 'pipe' })
+        execSync(`git add .`, { cwd: projectPath, stdio: 'pipe' })
+        execSync(`git commit -m "Initial commit"`, { cwd: projectPath, stdio: 'pipe' })
+        addLog(state, `> Git repo initialized with initial commit`)
+
+        // Try to create GitHub repo using gh CLI
+        try {
+          const projectName = path.basename(projectPath)
+          execSync(`gh repo create ${projectName} --private --source=. --push`, { cwd: projectPath, stdio: 'pipe' })
+          addLog(state, `> GitHub repo created: ${projectName}`)
+        } catch (ghErr: any) {
+          addLog(state, `> GitHub repo not created (local only): ${ghErr.message}`)
+        }
+      } catch (gitErr: any) {
+        addLog(state, `> Git init warning: ${gitErr.message}`)
+      }
+    }
+
+    // Create git branch
+    try {
+      try {
+        execSync(`git rev-parse --verify ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
+        addLog(state, `> Branch ${ralphData.branchName} already exists, checking out...`)
+        execSync(`git checkout ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
+      } catch {
+        addLog(state, `> Creating branch: ${ralphData.branchName}`)
+        execSync(`git checkout -b ${ralphData.branchName}`, { cwd: projectPath, stdio: 'pipe' })
+      }
+    } catch (err: any) {
+      addLog(state, `> Git warning: ${err.message}`)
+    }
+
+    addLog(state, '')
+    addLog(state, `> Starting Ralphy Boy loop...`)
+    addLog(state, `> This may take several minutes per story.`)
+    addLog(state, '')
+
+    // Save the prompt to a file to avoid shell escaping issues
+    const promptPath = '/tmp/ralph-prompt.txt'
+    const promptContent = buildRalphPrompt(ralphData)
+    fs.writeFileSync(promptPath, promptContent)
+
+    // Create the Ralph runner script with proper loop
+    const scriptPath = '/tmp/ralph-runner.sh'
+    const logPath = '/tmp/ralph-output.log'
+
+    // Ensure ralph user can write to the project
+    try {
+      execSync(`chown -R ralph:ralph "${projectPath}"`, { stdio: 'pipe' })
+    } catch {}
+
+    const script = `#!/bin/bash
+set -e
+
+cd "${projectPath}"
+export HOME="/home/ralph"
+export NVM_DIR="/home/ralph/.nvm"
+export PATH="/home/ralph/.nvm/versions/node/v22.19.0/bin:$PATH"
+export ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY || ''}"
+
+PROGRESS_FILE="${path.join(ralphDir, 'progress.txt')}"
+LOG_FILE="${logPath}"
+
+# Clear log file
+> "$LOG_FILE"
+
+echo "=== RALPHY BOY STARTING ===" >> "$LOG_FILE"
+echo "Project: ${projectPath}" >> "$LOG_FILE"
+echo "Branch: ${ralphData.branchName}" >> "$LOG_FILE"
+echo "Running as: $(whoami)" >> "$LOG_FILE"
+echo "" >> "$LOG_FILE"
+
+# Loop through each story
+${ralphData.userStories.map((story: any, idx: number) => `
+# Story ${story.id}
+echo "=== Starting ${story.id}: ${story.title.replace(/"/g, '\\"')} ===" >> "$LOG_FILE"
+sed -i 's/${story.id}: PENDING/${story.id}: RUNNING/' "$PROGRESS_FILE"
+
+# Create story-specific prompt
+cat > /tmp/ralph-story-${story.id}.txt << 'STORYPROMPT'
+You are Ralphy Boy. Implement this ONE story for project ${ralphData.project}:
+
+STORY: ${story.id} - ${story.title}
+DESCRIPTION: ${story.description}
+
+ACCEPTANCE CRITERIA:
+${story.acceptanceCriteria.map((c: string) => `- ${c}`).join('\n')}
+
+INSTRUCTIONS:
+1. Implement only this story
+2. Run typecheck: npm run build (or tsc --noEmit)
+3. Commit: git add . && git commit -m "feat(${story.id}): ${story.title.replace(/"/g, '\\"')}"
+4. Announce when done: "Completed ${story.id}"
+
+Start implementation now.
+STORYPROMPT
+
+# Run Gemini-driven runner for this story (no Claude API hits)
+echo "Running Gemini for ${story.id}..." >> "$LOG_FILE"
+if /home/ralph/.nvm/versions/node/v22.19.0/bin/node ${process.cwd()}/scripts/ralph-llm-runner.js --prompt-file /tmp/ralph-story-${story.id}.txt --project "${projectPath}" >> "$LOG_FILE" 2>&1; then
+  echo "Completed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: DONE/' "$PROGRESS_FILE"
+else
+  echo "Failed ${story.id}" >> "$LOG_FILE"
+  sed -i 's/${story.id}: RUNNING/${story.id}: FAIL/' "$PROGRESS_FILE"
+fi
+echo "" >> "$LOG_FILE"
+`).join('\n')}
+
+echo "=== RALPH_COMPLETE ===" >> "$LOG_FILE"
+`
+    fs.writeFileSync(scriptPath, script)
+    fs.chmodSync(scriptPath, '755')
+
+    // Clear previous log
+    if (fs.existsSync(logPath)) {
+      fs.unlinkSync(logPath)
+    }
+
+    // Spawn the process in background as ralph user (non-root) using sudo
+    const proc = spawn('sudo', ['-u', 'ralph', 'bash', scriptPath], {
+      detached: true,
+      stdio: 'ignore',
+      cwd: projectPath,
+      env: {
+        ...process.env,
+        HOME: '/home/ralph',
+        PATH: '/home/ralph/.nvm/versions/node/v22.19.0/bin:' + process.env.PATH,
+        ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || ''
+      }
+    })
+    proc.unref()
+
+    state.pid = proc.pid
+    saveState(state)
+
+    return NextResponse.json({
+      success: true,
+      message: 'Ralph started',
+      branchName: ralphData.branchName,
+      pid: proc.pid
+    })
+
+  } catch (error: any) {
+    console.error('Ralph run error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
+
+function buildRalphPrompt(ralphData: any): string {
+  const stories = ralphData.userStories.map((s: any) => {
+    const criteria = s.acceptanceCriteria.map((c: string) => `    - ${c}`).join('\n')
+    return `${s.id}: ${s.title}
+  Description: ${s.description}
+  Priority: ${s.priority}
+  Acceptance Criteria:
+${criteria}`
+  }).join('\n\n')
+
+  return `You are Ralphy Boy, an autonomous development agent. Implement these user stories for the project.
+
+PROJECT: ${ralphData.project}
+BRANCH: ${ralphData.branchName}
+
+USER STORIES:
+${stories}
+
+INSTRUCTIONS:
+1. Work through each story in priority order
+2. For each story: announce "Starting US-XXX", implement, then announce "Completed US-XXX"
+3. Update scripts/ralph/progress.txt with "US-XXX: DONE" after each
+4. Commit after each story: git commit -m "feat(US-XXX): title"
+5. Run typecheck after changes
+6. If a story fails, mark "US-XXX: FAIL" and continue
+
+Begin now.`
+}
+
+function generateProgressFile(ralphData: any): string {
+  const header = `# Ralph Progress
+# Project: ${ralphData.project}
+# Branch: ${ralphData.branchName}
+# Started: ${new Date().toISOString()}
+
+## Stories
+`
+  const stories = ralphData.userStories.map((s: any) =>
+    `${s.id}: PENDING - ${s.title}`
+  ).join('\n')
+
+  return header + stories + '\n\n## Log\n'
+}
diff --git a/app/api/ralph/status/route.ts b/app/api/ralph/status/route.ts
new file mode 100644
index 0000000..39e825c
--- /dev/null
+++ b/app/api/ralph/status/route.ts
@@ -0,0 +1,185 @@
+import { NextResponse } from 'next/server'
+import * as fs from 'fs'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+const LOG_FILE = '/tmp/ralph-output.log'
+
+interface RalphState {
+  running: boolean
+  projectPath: string
+  branchName: string
+  startTime: number
+  currentStoryIndex: number
+  logs: string[]
+  storyResults: Record<string, 'pending' | 'running' | 'pass' | 'fail'>
+  storyTimes: Record<string, number>
+  storyNotes: Record<string, string>
+  userStories: any[]
+  completed: boolean
+  pid?: number
+}
+
+function getState(): RalphState {
+  if (fs.existsSync(STATE_FILE)) {
+    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+  }
+  return {
+    running: false,
+    projectPath: '',
+    branchName: '',
+    startTime: 0,
+    currentStoryIndex: 0,
+    logs: [],
+    storyResults: {},
+    storyTimes: {},
+    storyNotes: {},
+    userStories: [],
+    completed: false
+  }
+}
+
+function saveState(state: RalphState) {
+  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+}
+
+export async function GET() {
+  try {
+    const state = getState()
+
+    // Ensure storyResults is initialized
+    if (!state.storyResults) {
+      state.storyResults = {}
+    }
+    if (!state.storyTimes) {
+      state.storyTimes = {}
+    }
+    if (!state.storyNotes) {
+      state.storyNotes = {}
+    }
+    if (!state.logs) {
+      state.logs = []
+    }
+
+    // Check if process is still running
+    if (state.pid) {
+      try {
+        process.kill(state.pid, 0)
+      } catch {
+        state.running = false
+      }
+    }
+
+    // Read latest output from log file
+    if (fs.existsSync(LOG_FILE)) {
+      const logContent = fs.readFileSync(LOG_FILE, 'utf-8')
+      const lines = logContent.split('\n').slice(-100)
+
+      for (const line of lines) {
+        const trimmedLine = line.trim()
+        if (!trimmedLine) continue
+
+        // Check for story start
+        const startMatch = trimmedLine.match(/(?:Starting|Working on)\s+(US-\d+)/i)
+        if (startMatch) {
+          const storyId = startMatch[1]
+          const idx = state.userStories.findIndex((s: any) => s.id === storyId)
+          if (idx !== -1) {
+            state.currentStoryIndex = idx
+            state.storyResults[storyId] = 'running'
+            const msg = '> Starting: ' + storyId
+            if (!state.logs.includes(msg)) state.logs.push(msg)
+          }
+        }
+
+        // Check for story completion - only accept valid story IDs
+        const doneMatch = trimmedLine.match(/(?:Completed|Done|DONE)\s+(US-\d+)/i)
+        if (doneMatch) {
+          const storyId = doneMatch[1]
+          // Validate story exists in current PRD
+          const storyExists = state.userStories.some((s: any) => s.id === storyId)
+          if (storyExists) {
+            state.storyResults[storyId] = 'pass'
+            state.storyTimes[storyId] = Math.floor((Date.now() - state.startTime) / 1000)
+            const msg = '> Completed: ' + storyId
+            if (!state.logs.includes(msg)) state.logs.push(msg)
+          }
+          // Ignore invalid story IDs (e.g., AI hallucinations)
+        }
+
+        // Check for story failure - only accept valid story IDs
+        const failMatch = trimmedLine.match(/(?:FAIL|Failed)\s+(US-\d+)/i)
+        if (failMatch) {
+          const storyId = failMatch[1]
+          // Validate story exists in current PRD
+          const storyExists = state.userStories.some((s: any) => s.id === storyId)
+          if (storyExists) {
+            state.storyResults[storyId] = 'fail'
+            const msg = '> Failed: ' + storyId
+            if (!state.logs.includes(msg)) state.logs.push(msg)
+          }
+          // Ignore invalid story IDs
+        }
+      }
+
+      // Check if completed
+      if (logContent.includes('RALPH_COMPLETE')) {
+        state.completed = true
+        state.running = false
+        if (!state.logs.includes('> Ralph completed')) {
+          state.logs.push('> Ralph completed')
+        }
+      }
+
+      if (state.logs.length > 200) {
+        state.logs = state.logs.slice(-200)
+      }
+
+      saveState(state)
+    }
+
+    // Check progress.txt - read directly regardless of userStories
+    if (state.projectPath) {
+      const progressPath = state.projectPath + '/scripts/ralph/progress.txt'
+      if (fs.existsSync(progressPath)) {
+        const progress = fs.readFileSync(progressPath, 'utf-8')
+        const lines = progress.split('\n')
+
+        for (const line of lines) {
+          // Parse lines like "US-001: DONE - Title"
+          const match = line.match(/^(US-\d+):\s*(DONE|PASS|FAIL|RUNNING|PENDING)/)
+          if (match) {
+            const storyId = match[1]
+            const status = match[2]
+            if (status === 'DONE' || status === 'PASS') {
+              state.storyResults[storyId] = 'pass'
+            } else if (status === 'FAIL') {
+              state.storyResults[storyId] = 'fail'
+            } else if (status === 'RUNNING') {
+              state.storyResults[storyId] = 'running'
+            } else if (status === 'PENDING') {
+              if (!state.storyResults[storyId]) {
+                state.storyResults[storyId] = 'pending'
+              }
+            }
+          }
+        }
+      }
+    }
+
+    return NextResponse.json({
+      running: state.running,
+      completed: state.completed,
+      currentStoryIndex: state.currentStoryIndex,
+      currentStory: state.userStories[state.currentStoryIndex]?.id,
+      logs: state.logs,
+      storyResults: state.storyResults,
+      storyTimes: state.storyTimes,
+      storyNotes: state.storyNotes,
+      elapsed: state.startTime ? Math.floor((Date.now() - state.startTime) / 1000) : 0
+    })
+
+  } catch (error: any) {
+    console.error('Ralph status error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/ralph/stop/route.ts b/app/api/ralph/stop/route.ts
new file mode 100644
index 0000000..3046c80
--- /dev/null
+++ b/app/api/ralph/stop/route.ts
@@ -0,0 +1,83 @@
+import { NextResponse } from 'next/server'
+import * as fs from 'fs'
+
+const STATE_FILE = '/tmp/ralph-state.json'
+
+interface RalphState {
+  running: boolean
+  pid?: number
+  logs: string[]
+  [key: string]: any
+}
+
+function getState(): RalphState {
+  if (fs.existsSync(STATE_FILE)) {
+    return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'))
+  }
+  return { running: false, logs: [] }
+}
+
+function saveState(state: RalphState) {
+  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
+}
+
+export async function POST() {
+  try {
+    const state = getState()
+    const { execSync } = require('child_process')
+    const killed: string[] = []
+
+    // Kill stored PID if exists
+    if (state.pid) {
+      try {
+        process.kill(-state.pid, 'SIGKILL')
+        killed.push(`pid ${state.pid}`)
+      } catch {
+        try {
+          process.kill(state.pid, 'SIGKILL')
+          killed.push(`pid ${state.pid}`)
+        } catch {}
+      }
+    }
+
+    // Kill ralph-runner script
+    try {
+      execSync('pkill -9 -f "ralph-runner" || true', { stdio: 'pipe' })
+      killed.push('ralph-runner')
+    } catch {}
+
+    // Kill any claude processes run by ralph user with dangerously-skip-permissions
+    try {
+      execSync('pkill -9 -f "claude.*dangerously-skip-permissions" || true', { stdio: 'pipe' })
+      killed.push('claude processes')
+    } catch {}
+
+    // Kill any bash processes running ralph scripts
+    try {
+      execSync('pkill -9 -f "ralph-story" || true', { stdio: 'pipe' })
+      killed.push('ralph-story')
+    } catch {}
+
+    // Kill sudo processes running ralph
+    try {
+      execSync('pkill -9 -f "sudo.*ralph" || true', { stdio: 'pipe' })
+      killed.push('sudo ralph')
+    } catch {}
+
+    // Update state
+    state.running = false
+    state.paused = false
+    state.pid = undefined
+    state.logs.push(`> STOPPED by user (killed: ${killed.join(', ')})`)
+    saveState(state)
+
+    return NextResponse.json({
+      success: true,
+      message: `Ralph stopped. Killed: ${killed.join(', ')}`
+    })
+
+  } catch (error: any) {
+    console.error('Ralph stop error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/api/tracking/route.ts b/app/api/tracking/route.ts
new file mode 100644
index 0000000..17d03f7
--- /dev/null
+++ b/app/api/tracking/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { trackEvent, getTrackingEvents, getProjectStats } from '@/lib/sheets'
+
+export async function POST(request: NextRequest) {
+  try {
+    const event = await request.json()
+
+    const success = await trackEvent({
+      ...event,
+      timestamp: new Date().toISOString()
+    })
+
+    return NextResponse.json({ success })
+  } catch (error) {
+    console.error('Failed to track event:', error)
+    return NextResponse.json({ error: 'Failed to track event' }, { status: 500 })
+  }
+}
+
+export async function GET(request: NextRequest) {
+  try {
+    const { searchParams } = new URL(request.url)
+    const project = searchParams.get('project')
+    const limit = parseInt(searchParams.get('limit') || '50')
+    const stats = searchParams.get('stats') === 'true'
+
+    if (stats && project) {
+      const projectStats = await getProjectStats(project)
+      return NextResponse.json(projectStats)
+    }
+
+    const events = await getTrackingEvents(project || undefined, limit)
+    return NextResponse.json({ events })
+  } catch (error) {
+    console.error('Failed to get tracking events:', error)
+    return NextResponse.json({ error: 'Failed to get events' }, { status: 500 })
+  }
+}
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
new file mode 100644
index 0000000..3d54607
--- /dev/null
+++ b/app/api/upload/route.ts
@@ -0,0 +1,68 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { writeFile, mkdir } from 'fs/promises'
+import path from 'path'
+
+export async function POST(request: NextRequest) {
+  try {
+    const formData = await request.formData()
+    const file = formData.get('file') as File
+    const projectPath = formData.get('projectPath') as string
+
+    if (!file) {
+      return NextResponse.json({ error: 'No file provided' }, { status: 400 })
+    }
+
+    // Create uploads directory if it doesn't exist
+    const uploadsDir = projectPath
+      ? path.join(projectPath, 'uploads')
+      : '/root/Projects/Letsbegin/uploads'
+
+    await mkdir(uploadsDir, { recursive: true })
+
+    // Generate unique filename
+    const timestamp = Date.now()
+    const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
+    const filename = `${timestamp}_${safeName}`
+    const filepath = path.join(uploadsDir, filename)
+
+    // Write file
+    const bytes = await file.arrayBuffer()
+    const buffer = Buffer.from(bytes)
+    await writeFile(filepath, buffer)
+
+    return NextResponse.json({
+      success: true,
+      filename,
+      filepath,
+      size: file.size,
+      type: file.type
+    })
+  } catch (error) {
+    console.error('Upload error:', error)
+    return NextResponse.json(
+      { error: 'Failed to upload file' },
+      { status: 500 }
+    )
+  }
+}
+
+export async function DELETE(request: NextRequest) {
+  try {
+    const { filepath } = await request.json()
+
+    if (!filepath || !filepath.includes('/uploads/')) {
+      return NextResponse.json({ error: 'Invalid filepath' }, { status: 400 })
+    }
+
+    const fs = await import('fs/promises')
+    await fs.unlink(filepath)
+
+    return NextResponse.json({ success: true })
+  } catch (error) {
+    console.error('Delete error:', error)
+    return NextResponse.json(
+      { error: 'Failed to delete file' },
+      { status: 500 }
+    )
+  }
+}
diff --git a/app/api/url-info/route.ts b/app/api/url-info/route.ts
new file mode 100644
index 0000000..3686063
--- /dev/null
+++ b/app/api/url-info/route.ts
@@ -0,0 +1,121 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+// Gemini API configuration (FREE - no credits needed!)
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+async function callGemini(prompt: string): Promise<string> {
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { temperature: 0.3, maxOutputTokens: 1024 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+export async function POST(request: NextRequest) {
+  try {
+    let body: { url?: string }
+    try {
+      body = await request.json()
+    } catch {
+      return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
+    }
+    const { url } = body
+
+    if (!url || !url.startsWith('http')) {
+      return NextResponse.json({ error: 'Invalid URL' }, { status: 400 })
+    }
+
+    // Fetch the URL content
+    let pageContent = ''
+    try {
+      const response = await fetch(url, {
+        headers: {
+          'User-Agent': 'Mozilla/5.0 (compatible; LetsBegin/1.0; +https://letsbegin.app)',
+          'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
+        },
+        signal: AbortSignal.timeout(10000) // 10 second timeout
+      })
+
+      if (!response.ok) {
+        throw new Error(`HTTP ${response.status}`)
+      }
+
+      const html = await response.text()
+
+      // Extract text content (simple HTML stripping)
+      pageContent = html
+        .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
+        .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
+        .replace(/<[^>]+>/g, ' ')
+        .replace(/\s+/g, ' ')
+        .substring(0, 15000) // Limit content size
+
+    } catch (fetchError: any) {
+      console.error('Fetch error:', fetchError)
+      // If fetch fails, use Claude to analyze based on URL alone
+      pageContent = `Unable to fetch page content. URL: ${url}`
+    }
+
+    // Use Gemini to extract structured info
+    const aiPrompt = `Analyze this webpage/app and extract key information. If the content couldn't be fetched, make reasonable inferences from the URL.
+
+URL: ${url}
+
+Page Content (truncated):
+${pageContent}
+
+Respond in this exact JSON format only, no other text:
+{
+  "title": "App/Site name",
+  "description": "One sentence description of what it does",
+  "features": ["feature 1", "feature 2", "feature 3", "feature 4", "feature 5"]
+}
+
+Extract the most notable features that would be useful for someone building a similar app. Focus on user-facing functionality.`
+
+    const responseText = await callGemini(aiPrompt)
+
+    try {
+      // Extract JSON from response (handle markdown code blocks)
+      let jsonStr = responseText
+      const jsonMatch = responseText.match(/\{[\s\S]*\}/)
+      if (jsonMatch) {
+        jsonStr = jsonMatch[0]
+      }
+
+      const parsed = JSON.parse(jsonStr)
+
+      return NextResponse.json({
+        title: parsed.title || 'Unknown',
+        description: parsed.description || 'No description available',
+        features: Array.isArray(parsed.features) ? parsed.features : []
+      })
+    } catch (parseError) {
+      console.error('Parse error:', parseError)
+      return NextResponse.json({
+        title: new URL(url).hostname,
+        description: 'Could not extract description',
+        features: []
+      })
+    }
+
+  } catch (error: any) {
+    console.error('URL info error:', error)
+    return NextResponse.json({ error: error.message }, { status: 500 })
+  }
+}
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..d2c861d
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,470 @@
+@import "tailwindcss";
+
+:root {
+  /* 21st.dev Inspired Dark Theme */
+  --background: #0a0a0f;
+  --surface: #12121a;
+  --surface-alt: #1a1a24;
+  --surface-elevated: #22222e;
+  --primary: #6366f1;
+  --primary-glow: #818cf8;
+  --primary-dark: #4f46e5;
+  --secondary: #a855f7;
+  --secondary-glow: #c084fc;
+  --accent: #22d3ee;
+  --success: #22c55e;
+  --success-bg: rgba(34, 197, 94, 0.1);
+  --warning: #f59e0b;
+  --warning-bg: rgba(245, 158, 11, 0.1);
+  --error: #ef4444;
+  --error-bg: rgba(239, 68, 68, 0.1);
+  --border: #2a2a3a;
+  --border-light: #3a3a4a;
+  --foreground: #fafafa;
+  --foreground-secondary: #a1a1aa;
+  --foreground-tertiary: #71717a;
+
+  /* Gradients */
+  --gradient-primary: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
+  --gradient-glow: linear-gradient(135deg, #818cf8 0%, #c084fc 100%);
+  --gradient-dark: linear-gradient(180deg, #0a0a0f 0%, #12121a 100%);
+
+  /* Shadows */
+  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
+  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
+  --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3);
+
+  /* Radius */
+  --radius-sm: 6px;
+  --radius-md: 10px;
+  --radius-lg: 16px;
+  --radius-xl: 24px;
+}
+
+* {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+html,
+body {
+  height: 100%;
+  min-height: 100vh;
+  background: var(--background);
+  color: var(--foreground);
+  font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  overflow: hidden;
+}
+
+/* Scrollbar Styling */
+::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+::-webkit-scrollbar-track {
+  background: var(--surface);
+}
+
+::-webkit-scrollbar-thumb {
+  background: var(--border-light);
+  border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: var(--foreground-tertiary);
+}
+
+/* Custom Classes */
+.gradient-text {
+  background: var(--gradient-primary);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+}
+
+.glow-border {
+  box-shadow: var(--shadow-glow);
+}
+
+.card {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+}
+
+.card-elevated {
+  background: var(--surface-elevated);
+  border: 1px solid var(--border-light);
+  border-radius: var(--radius-lg);
+  box-shadow: var(--shadow-md);
+}
+
+/* Button Styles */
+.btn-primary {
+  background: var(--gradient-primary);
+  color: white;
+  border: none;
+  padding: 10px 20px;
+  border-radius: var(--radius-md);
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.btn-primary:hover {
+  background: var(--gradient-glow);
+  box-shadow: var(--shadow-glow);
+  transform: translateY(-1px);
+}
+
+.btn-secondary {
+  background: var(--surface-alt);
+  color: var(--foreground);
+  border: 1px solid var(--border);
+  padding: 10px 20px;
+  border-radius: var(--radius-md);
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.btn-secondary:hover {
+  background: var(--surface-elevated);
+  border-color: var(--border-light);
+}
+
+/* Input Styles */
+.input {
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 12px 16px;
+  color: var(--foreground);
+  font-size: 14px;
+  width: 100%;
+  transition: all 0.2s ease;
+}
+
+.input:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
+}
+
+.input::placeholder {
+  color: var(--foreground-tertiary);
+}
+
+/* Textarea */
+.textarea {
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 12px 16px;
+  color: var(--foreground);
+  font-size: 14px;
+  width: 100%;
+  min-height: 100px;
+  resize: vertical;
+  font-family: inherit;
+  transition: all 0.2s ease;
+}
+
+.textarea:focus {
+  outline: none;
+  border-color: var(--primary);
+  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
+}
+
+/* Tab Styles */
+.tab {
+  padding: 12px 24px;
+  background: transparent;
+  border: none;
+  color: var(--foreground-secondary);
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  position: relative;
+}
+
+.tab:hover {
+  color: var(--foreground);
+}
+
+.tab.active {
+  color: var(--primary);
+}
+
+.tab.active::after {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 2px;
+  background: var(--primary);
+}
+
+.tab.completed {
+  color: var(--success);
+}
+
+/* Collapsible Section */
+.collapsible-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.collapsible-header:hover {
+  background: var(--surface-elevated);
+}
+
+.collapsible-content {
+  overflow: hidden;
+  transition: max-height 0.3s ease;
+}
+
+/* Radio & Checkbox */
+.radio-group {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.radio-option {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.radio-option:hover {
+  border-color: var(--primary);
+}
+
+.radio-option.selected {
+  border-color: var(--primary);
+  background: rgba(99, 102, 241, 0.1);
+}
+
+.radio-dot {
+  width: 18px;
+  height: 18px;
+  border: 2px solid var(--border-light);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.radio-dot.selected {
+  border-color: var(--primary);
+}
+
+.radio-dot.selected::after {
+  content: '';
+  width: 10px;
+  height: 10px;
+  background: var(--primary);
+  border-radius: 50%;
+}
+
+/* Checkbox */
+.checkbox-option {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 10px 14px;
+  background: var(--surface-alt);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.checkbox-option:hover {
+  border-color: var(--primary);
+}
+
+.checkbox-option.checked {
+  border-color: var(--success);
+  background: var(--success-bg);
+}
+
+.checkbox-box {
+  width: 18px;
+  height: 18px;
+  border: 2px solid var(--border-light);
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.checkbox-box.checked {
+  background: var(--success);
+  border-color: var(--success);
+}
+
+/* Progress Bar */
+.progress-bar {
+  height: 8px;
+  background: var(--surface-alt);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  background: var(--gradient-primary);
+  transition: width 0.3s ease;
+}
+
+/* Status Badges */
+.badge {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  border-radius: 9999px;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.badge-success {
+  background: var(--success-bg);
+  color: var(--success);
+}
+
+.badge-warning {
+  background: var(--warning-bg);
+  color: var(--warning);
+}
+
+.badge-error {
+  background: var(--error-bg);
+  color: var(--error);
+}
+
+.badge-primary {
+  background: rgba(99, 102, 241, 0.1);
+  color: var(--primary);
+}
+
+/* Code Block */
+.code-block {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 16px;
+  font-family: 'JetBrains Mono', 'Fira Code', monospace;
+  font-size: 13px;
+  overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+/* Tree View */
+.tree-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 8px;
+  cursor: pointer;
+  border-radius: var(--radius-sm);
+  transition: all 0.15s ease;
+}
+
+.tree-item:hover {
+  background: var(--surface-alt);
+}
+
+.tree-item.selected {
+  background: rgba(99, 102, 241, 0.15);
+  color: var(--primary);
+}
+
+.tree-chevron {
+  width: 16px;
+  height: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: transform 0.2s ease;
+}
+
+.tree-chevron.expanded {
+  transform: rotate(90deg);
+}
+
+/* Animation */
+@keyframes pulse-glow {
+  0%, 100% {
+    box-shadow: 0 0 10px rgba(99, 102, 241, 0.3);
+  }
+  50% {
+    box-shadow: 0 0 20px rgba(99, 102, 241, 0.5);
+  }
+}
+
+.animate-pulse-glow {
+  animation: pulse-glow 2s ease-in-out infinite;
+}
+
+@keyframes spin {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+
+.animate-spin {
+  animation: spin 1s linear infinite;
+}
+
+/* Resizable Panel Handles */
+[data-panel-group-direction="horizontal"] > [data-panel-resize-handle-id] {
+  width: 6px;
+  margin: 0 2px;
+}
+
+[data-panel-group-direction="vertical"] > [data-panel-resize-handle-id] {
+  height: 6px;
+  margin: 2px 0;
+}
+
+[data-panel-resize-handle-id]:hover {
+  background: rgba(99, 102, 241, 0.2);
+}
+
+[data-panel-resize-handle-id]:active {
+  background: rgba(99, 102, 241, 0.4);
+}
+
+/* Process Status Bar Animations */
+@keyframes blink {
+  0%, 50%, 100% { opacity: 1; }
+  25%, 75% { opacity: 0.5; }
+}
+
+.animate-blink {
+  animation: blink 2s ease-in-out infinite;
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..dbadd54
--- /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="#10b981"/>
+<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">L</text>
+</svg>
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..7a4ed11
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+import './globals.css'
+import { AuthProvider } from '@/components/AuthProvider'
+
+export const metadata: Metadata = {
+  title: 'GoodQuestionRalph - AI Project Planner',
+  description: 'From Idea to Implementation - PRD Generator, Ralph Converter, Ralphy Boy Runner',
+}
+
+export default function RootLayout({
+  children,
+}: {
+  children: React.ReactNode
+}) {
+  return (
+    <html lang="en">
+      <body>
+        <AuthProvider>{children}</AuthProvider>
+      </body>
+    </html>
+  )
+}
diff --git a/app/live/page.tsx b/app/live/page.tsx
new file mode 100644
index 0000000..dc7fea5
--- /dev/null
+++ b/app/live/page.tsx
@@ -0,0 +1,944 @@
+'use client'
+
+import { useState, useEffect, useRef } from 'react'
+import {
+  Activity,
+  Cpu,
+  MemoryStick,
+  Terminal,
+  GitBranch,
+  CheckCircle2,
+  Circle,
+  Loader2,
+  XCircle,
+  RefreshCw,
+  Zap,
+  Clock,
+  Server,
+  ChevronDown,
+  ChevronRight,
+  AlertTriangle,
+  Play,
+  Square,
+  Info,
+  Folder,
+  FileText,
+  Layers,
+  ArrowLeft
+} from 'lucide-react'
+import Link from 'next/link'
+
+interface ClaudeProcess {
+  pid: number
+  cpu: string
+  memory: string
+  terminal: string
+  uptime: string
+  command: string
+  type: 'interactive' | 'ralph' | 'mcp' | 'unknown'
+  project?: string
+  status: string
+}
+
+interface StoryDetail {
+  id: string
+  title: string
+  status: 'done' | 'running' | 'pending' | 'fail'
+  time?: number
+}
+
+interface RalphStatus {
+  running: boolean
+  project: string
+  projectPath: string
+  branch: string
+  currentStory: string
+  progress: string
+  completedStories: string[]
+  pendingStories: string[]
+  runningStory: string | null
+  logs: string[]
+  elapsed: number
+  storyDetails: StoryDetail[]
+}
+
+interface LiveData {
+  timestamp: string
+  claudeProcesses: ClaudeProcess[]
+  ralphStatus: RalphStatus | null
+  systemStats: {
+    loadAvg: number[]
+    memUsed: number
+    memTotal: number
+    uptime: string
+  }
+  totalProcesses: number
+  activeRalph: boolean
+}
+
+interface RalphProject {
+  project: string
+  projectPath: string
+  pid: number
+  cpu: string
+  memory: string
+  status: 'running' | 'completed' | 'failed' | 'idle'
+  currentStory: string
+  startTime: string
+  progress: string
+}
+
+interface ProcessDetails {
+  status: string
+  message: string
+  processInfo: {
+    pid: number
+    state: string
+    cpu: number
+    memory: number
+    elapsed: string
+    command: string
+  } | null
+  detailedInfo: {
+    cwd?: string
+    cmdline?: string
+    openFiles?: number
+    threads?: number
+    VmRSS?: string
+    VmSize?: string
+    VmPeak?: string
+    ralphStatus?: {
+      running: boolean
+      currentStory: string
+      progress: string
+      lastLog: string[]
+    }
+    stuckIndicators?: string[]
+  }
+  stateDescription?: string
+}
+
+function formatElapsed(seconds: number): string {
+  const hrs = Math.floor(seconds / 3600)
+  const mins = Math.floor((seconds % 3600) / 60)
+  const secs = seconds % 60
+  if (hrs > 0) return `${hrs}h ${mins}m`
+  if (mins > 0) return `${mins}m ${secs}s`
+  return `${secs}s`
+}
+
+function ProcessTypeIcon({ type }: { type: ClaudeProcess['type'] }) {
+  switch (type) {
+    case 'interactive':
+      return <Terminal className="w-4 h-4 text-blue-400" />
+    case 'ralph':
+      return <Zap className="w-4 h-4 text-yellow-400" />
+    case 'mcp':
+      return <Server className="w-4 h-4 text-purple-400" />
+    default:
+      return <Circle className="w-4 h-4 text-gray-400" />
+  }
+}
+
+function StoryStatusIcon({ status }: { status: StoryDetail['status'] }) {
+  switch (status) {
+    case 'done':
+      return <CheckCircle2 className="w-5 h-5 text-green-500" />
+    case 'running':
+      return <Loader2 className="w-5 h-5 text-yellow-500 animate-spin" />
+    case 'fail':
+      return <XCircle className="w-5 h-5 text-red-500" />
+    default:
+      return <Circle className="w-5 h-5 text-gray-500" />
+  }
+}
+
+function StateIndicator({ state }: { state: string }) {
+  const colors: Record<string, string> = {
+    'R': 'bg-green-500',
+    'S': 'bg-blue-500',
+    'D': 'bg-red-500',
+    'T': 'bg-yellow-500',
+    'Z': 'bg-gray-500',
+  }
+  return (
+    <span className={`inline-block w-2 h-2 rounded-full ${colors[state] || 'bg-gray-400'}`} />
+  )
+}
+
+export default function LiveDashboard() {
+  const [data, setData] = useState<LiveData | null>(null)
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [autoRefresh, setAutoRefresh] = useState(true)
+  const [selectedPid, setSelectedPid] = useState<number | null>(null)
+  const [processDetails, setProcessDetails] = useState<ProcessDetails | null>(null)
+  const [checkingProcess, setCheckingProcess] = useState(false)
+  const [restartingProcess, setRestartingProcess] = useState(false)
+  const [showProcessTable, setShowProcessTable] = useState(true)
+  const [allProjects, setAllProjects] = useState<RalphProject[]>([])
+  const [bottomBarExpanded, setBottomBarExpanded] = useState(true)
+  const logsEndRef = useRef<HTMLDivElement>(null)
+
+  const fetchProjects = async () => {
+    try {
+      const res = await fetch('/api/processes')
+      if (!res.ok) return
+      const json = await res.json()
+      setAllProjects(json.processes || [])
+    } catch (err) {
+      // Silent fail for projects bar
+    }
+  }
+
+  const fetchData = async () => {
+    try {
+      const res = await fetch('/api/live')
+      if (!res.ok) throw new Error('Failed to fetch')
+      const json = await res.json()
+      setData(json)
+      setError(null)
+    } catch (err) {
+      setError('Failed to fetch live data')
+    } finally {
+      setLoading(false)
+    }
+  }
+
+  const checkProcess = async (pid: number) => {
+    setCheckingProcess(true)
+    try {
+      const res = await fetch('/api/live/check', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ pid })
+      })
+      const json = await res.json()
+      setProcessDetails(json)
+    } catch (err) {
+      setProcessDetails({
+        status: 'error',
+        message: 'Failed to check process',
+        processInfo: null,
+        detailedInfo: {}
+      })
+    } finally {
+      setCheckingProcess(false)
+    }
+  }
+
+  const restartProcess = async (pid: number) => {
+    setRestartingProcess(true)
+    try {
+      const res = await fetch('/api/live/check', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ pid, action: 'restart' })
+      })
+      const json = await res.json()
+      setProcessDetails(json)
+      // Refresh the main data after restart
+      setTimeout(fetchData, 2000)
+    } catch (err) {
+      setProcessDetails({
+        status: 'error',
+        message: 'Failed to restart process',
+        processInfo: null,
+        detailedInfo: {}
+      })
+    } finally {
+      setRestartingProcess(false)
+    }
+  }
+
+  useEffect(() => {
+    fetchData()
+    fetchProjects()
+    if (autoRefresh) {
+      const interval = setInterval(() => {
+        fetchData()
+        fetchProjects()
+      }, 3000)
+      return () => clearInterval(interval)
+    }
+  }, [autoRefresh])
+
+  useEffect(() => {
+    if (selectedPid) {
+      checkProcess(selectedPid)
+    }
+  }, [selectedPid])
+
+  useEffect(() => {
+    logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+  }, [data?.ralphStatus?.logs])
+
+  if (loading && !data) {
+    return (
+      <div className="min-h-screen bg-gray-950 flex items-center justify-center">
+        <Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
+      </div>
+    )
+  }
+
+  return (
+    <div className="min-h-screen bg-gray-950 text-gray-100 p-6">
+      {/* Header */}
+      <header className="mb-8">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-3">
+            <Link
+              href="/"
+              className="p-2 rounded-lg hover:bg-gray-800 transition-colors"
+            >
+              <ArrowLeft className="w-5 h-5 text-gray-400" />
+            </Link>
+            <Activity className="w-8 h-8 text-blue-500" />
+            <h1 className="text-2xl font-bold">Claude Live Dashboard</h1>
+            <span className="px-2 py-1 text-xs rounded-full bg-green-500/20 text-green-400 flex items-center gap-1">
+              <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
+              Live
+            </span>
+          </div>
+          <div className="flex items-center gap-4">
+            <button
+              onClick={() => setAutoRefresh(!autoRefresh)}
+              className={`px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 transition-colors ${
+                autoRefresh
+                  ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
+                  : 'bg-gray-800 text-gray-400 border border-gray-700'
+              }`}
+            >
+              <RefreshCw className={`w-4 h-4 ${autoRefresh ? 'animate-spin' : ''}`} />
+              Auto-refresh {autoRefresh ? 'ON' : 'OFF'}
+            </button>
+            <span className="text-sm text-gray-500">
+              {data?.timestamp ? new Date(data.timestamp).toLocaleTimeString() : ''}
+            </span>
+          </div>
+        </div>
+      </header>
+
+      {error && (
+        <div className="mb-6 p-4 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400">
+          {error}
+        </div>
+      )}
+
+      {/* System Stats */}
+      <div className="grid grid-cols-4 gap-4 mb-8">
+        <div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
+          <div className="flex items-center gap-2 text-gray-400 text-sm mb-2">
+            <Cpu className="w-4 h-4" />
+            CPU Load
+          </div>
+          <div className="text-2xl font-bold">
+            {data?.systemStats?.loadAvg?.[0]?.toFixed(2) || '0.00'}
+          </div>
+          <div className="text-xs text-gray-500 mt-1">
+            5m: {data?.systemStats?.loadAvg?.[1]?.toFixed(2)} | 15m: {data?.systemStats?.loadAvg?.[2]?.toFixed(2)}
+          </div>
+        </div>
+
+        <div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
+          <div className="flex items-center gap-2 text-gray-400 text-sm mb-2">
+            <MemoryStick className="w-4 h-4" />
+            Memory
+          </div>
+          <div className="text-2xl font-bold">
+            {data?.systemStats?.memUsed || 0} MB
+          </div>
+          <div className="text-xs text-gray-500 mt-1">
+            of {data?.systemStats?.memTotal || 0} MB ({Math.round((data?.systemStats?.memUsed || 0) / (data?.systemStats?.memTotal || 1) * 100)}%)
+          </div>
+        </div>
+
+        <div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
+          <div className="flex items-center gap-2 text-gray-400 text-sm mb-2">
+            <Terminal className="w-4 h-4" />
+            Claude Processes
+          </div>
+          <div className="text-2xl font-bold text-blue-400">
+            {data?.totalProcesses || 0}
+          </div>
+          <div className="text-xs text-gray-500 mt-1">
+            Active instances
+          </div>
+        </div>
+
+        <div className="p-4 rounded-xl bg-gray-900 border border-gray-800">
+          <div className="flex items-center gap-2 text-gray-400 text-sm mb-2">
+            <Clock className="w-4 h-4" />
+            System Uptime
+          </div>
+          <div className="text-lg font-bold">
+            {data?.systemStats?.uptime || 'Unknown'}
+          </div>
+        </div>
+      </div>
+
+      {/* Collapsible Claude Processes Table */}
+      <div className="mb-8 rounded-xl bg-gray-900 border border-gray-800 overflow-hidden">
+        <button
+          onClick={() => setShowProcessTable(!showProcessTable)}
+          className="w-full px-4 py-3 border-b border-gray-800 flex items-center gap-2 hover:bg-gray-800/50 transition-colors"
+        >
+          {showProcessTable ? (
+            <ChevronDown className="w-5 h-5 text-gray-400" />
+          ) : (
+            <ChevronRight className="w-5 h-5 text-gray-400" />
+          )}
+          <Terminal className="w-5 h-5 text-blue-400" />
+          <h2 className="font-semibold">Claude Processes</h2>
+          <span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
+            {data?.claudeProcesses?.length || 0} running
+          </span>
+        </button>
+
+        {showProcessTable && (
+          <div className="overflow-x-auto">
+            <table className="w-full text-sm">
+              <thead className="bg-gray-800/50">
+                <tr>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Select</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">PID</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Type</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Terminal</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">CPU %</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Mem %</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Uptime</th>
+                  <th className="px-4 py-2 text-left text-gray-400 font-medium">Command</th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-gray-800">
+                {data?.claudeProcesses?.map((proc) => (
+                  <tr
+                    key={proc.pid}
+                    className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${
+                      selectedPid === proc.pid ? 'bg-blue-500/10 border-l-2 border-blue-500' : ''
+                    }`}
+                    onClick={() => setSelectedPid(proc.pid)}
+                  >
+                    <td className="px-4 py-3">
+                      <input
+                        type="radio"
+                        name="selectedProcess"
+                        checked={selectedPid === proc.pid}
+                        onChange={() => setSelectedPid(proc.pid)}
+                        className="w-4 h-4 text-blue-500 bg-gray-800 border-gray-600 focus:ring-blue-500"
+                      />
+                    </td>
+                    <td className="px-4 py-3 font-mono text-gray-300">{proc.pid}</td>
+                    <td className="px-4 py-3">
+                      <span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs ${
+                        proc.type === 'interactive' ? 'bg-blue-500/20 text-blue-400' :
+                        proc.type === 'ralph' ? 'bg-yellow-500/20 text-yellow-400' :
+                        proc.type === 'mcp' ? 'bg-purple-500/20 text-purple-400' :
+                        'bg-gray-500/20 text-gray-400'
+                      }`}>
+                        <ProcessTypeIcon type={proc.type} />
+                        {proc.type}
+                      </span>
+                    </td>
+                    <td className="px-4 py-3 text-gray-400">{proc.terminal}</td>
+                    <td className="px-4 py-3 text-gray-300">{proc.cpu}</td>
+                    <td className="px-4 py-3 text-gray-300">{proc.memory}</td>
+                    <td className="px-4 py-3 text-gray-400">{proc.uptime}</td>
+                    <td className="px-4 py-3 text-gray-500 truncate max-w-xs">{proc.command}</td>
+                  </tr>
+                ))}
+                {(!data?.claudeProcesses || data.claudeProcesses.length === 0) && (
+                  <tr>
+                    <td colSpan={8} className="px-4 py-8 text-center text-gray-500">
+                      No Claude processes running
+                    </td>
+                  </tr>
+                )}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+
+      {/* Selected Process Details */}
+      {selectedPid && (
+        <div className="mb-8 rounded-xl bg-gray-900 border border-gray-800 overflow-hidden">
+          <div className="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Info className="w-5 h-5 text-blue-400" />
+              <h2 className="font-semibold">Process Details - PID {selectedPid}</h2>
+            </div>
+            <div className="flex items-center gap-2">
+              <button
+                onClick={() => checkProcess(selectedPid)}
+                disabled={checkingProcess}
+                className="px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 bg-blue-500/20 text-blue-400 border border-blue-500/30 hover:bg-blue-500/30 transition-colors disabled:opacity-50"
+              >
+                {checkingProcess ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <RefreshCw className="w-4 h-4" />
+                )}
+                Check Status
+              </button>
+              <button
+                onClick={() => restartProcess(selectedPid)}
+                disabled={restartingProcess}
+                className="px-3 py-1.5 rounded-lg text-sm flex items-center gap-2 bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-colors disabled:opacity-50"
+              >
+                {restartingProcess ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <Square className="w-4 h-4" />
+                )}
+                Kill & Restart
+              </button>
+              <button
+                onClick={() => {
+                  setSelectedPid(null)
+                  setProcessDetails(null)
+                }}
+                className="px-3 py-1.5 rounded-lg text-sm bg-gray-800 text-gray-400 hover:bg-gray-700 transition-colors"
+              >
+                Close
+              </button>
+            </div>
+          </div>
+
+          {checkingProcess && !processDetails ? (
+            <div className="px-4 py-8 flex items-center justify-center">
+              <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
+            </div>
+          ) : processDetails ? (
+            <div className="p-4">
+              {/* Status Banner */}
+              <div className={`mb-4 p-4 rounded-lg border ${
+                processDetails.status === 'running' ? 'bg-green-500/10 border-green-500/30' :
+                processDetails.status === 'stuck' ? 'bg-yellow-500/10 border-yellow-500/30' :
+                processDetails.status === 'dead' ? 'bg-red-500/10 border-red-500/30' :
+                processDetails.status === 'killed' || processDetails.status === 'restarted' ? 'bg-blue-500/10 border-blue-500/30' :
+                'bg-gray-500/10 border-gray-500/30'
+              }`}>
+                <div className="flex items-center gap-2">
+                  {processDetails.status === 'running' && <CheckCircle2 className="w-5 h-5 text-green-500" />}
+                  {processDetails.status === 'stuck' && <AlertTriangle className="w-5 h-5 text-yellow-500" />}
+                  {processDetails.status === 'dead' && <XCircle className="w-5 h-5 text-red-500" />}
+                  {(processDetails.status === 'killed' || processDetails.status === 'restarted') && <CheckCircle2 className="w-5 h-5 text-blue-500" />}
+                  <span className={`font-medium ${
+                    processDetails.status === 'running' ? 'text-green-400' :
+                    processDetails.status === 'stuck' ? 'text-yellow-400' :
+                    processDetails.status === 'dead' ? 'text-red-400' :
+                    'text-blue-400'
+                  }`}>
+                    {processDetails.message}
+                  </span>
+                </div>
+                {processDetails.stateDescription && (
+                  <div className="mt-2 text-sm text-gray-400">
+                    State: {processDetails.stateDescription}
+                  </div>
+                )}
+              </div>
+
+              {/* Stuck Indicators */}
+              {processDetails.detailedInfo?.stuckIndicators && processDetails.detailedInfo.stuckIndicators.length > 0 && (
+                <div className="mb-4 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+                  <div className="flex items-center gap-2 mb-2">
+                    <AlertTriangle className="w-5 h-5 text-yellow-500" />
+                    <span className="font-medium text-yellow-400">Potential Issues Detected</span>
+                  </div>
+                  <ul className="list-disc list-inside text-sm text-yellow-300">
+                    {processDetails.detailedInfo.stuckIndicators.map((indicator, i) => (
+                      <li key={i}>{indicator}</li>
+                    ))}
+                  </ul>
+                </div>
+              )}
+
+              {/* Process Info Grid */}
+              {processDetails.processInfo && (
+                <div className="grid grid-cols-3 gap-4 mb-4">
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">State</div>
+                    <div className="flex items-center gap-2">
+                      <StateIndicator state={processDetails.processInfo.state} />
+                      <span className="font-mono text-gray-300">{processDetails.processInfo.state}</span>
+                    </div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">CPU</div>
+                    <div className="font-mono text-gray-300">{processDetails.processInfo.cpu}%</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">Memory</div>
+                    <div className="font-mono text-gray-300">{processDetails.processInfo.memory}%</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">Elapsed</div>
+                    <div className="font-mono text-gray-300">{processDetails.processInfo.elapsed}</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">Open Files</div>
+                    <div className="font-mono text-gray-300">{processDetails.detailedInfo?.openFiles || 'N/A'}</div>
+                  </div>
+                  <div className="p-3 rounded-lg bg-gray-800/50">
+                    <div className="text-xs text-gray-500 mb-1">Threads</div>
+                    <div className="font-mono text-gray-300">{processDetails.detailedInfo?.threads || 'N/A'}</div>
+                  </div>
+                </div>
+              )}
+
+              {/* Memory Details */}
+              {(processDetails.detailedInfo?.VmRSS || processDetails.detailedInfo?.VmSize) && (
+                <div className="mb-4 p-4 rounded-lg bg-gray-800/50">
+                  <div className="flex items-center gap-2 mb-3">
+                    <MemoryStick className="w-4 h-4 text-purple-400" />
+                    <span className="font-medium text-gray-300">Memory Details</span>
+                  </div>
+                  <div className="grid grid-cols-3 gap-4 text-sm">
+                    <div>
+                      <div className="text-gray-500">Resident (VmRSS)</div>
+                      <div className="font-mono text-gray-300">{processDetails.detailedInfo.VmRSS || 'N/A'}</div>
+                    </div>
+                    <div>
+                      <div className="text-gray-500">Virtual (VmSize)</div>
+                      <div className="font-mono text-gray-300">{processDetails.detailedInfo.VmSize || 'N/A'}</div>
+                    </div>
+                    <div>
+                      <div className="text-gray-500">Peak (VmPeak)</div>
+                      <div className="font-mono text-gray-300">{processDetails.detailedInfo.VmPeak || 'N/A'}</div>
+                    </div>
+                  </div>
+                </div>
+              )}
+
+              {/* Working Directory */}
+              {processDetails.detailedInfo?.cwd && (
+                <div className="mb-4 p-4 rounded-lg bg-gray-800/50">
+                  <div className="flex items-center gap-2 mb-2">
+                    <Folder className="w-4 h-4 text-blue-400" />
+                    <span className="font-medium text-gray-300">Working Directory</span>
+                  </div>
+                  <code className="text-sm text-gray-400 break-all">{processDetails.detailedInfo.cwd}</code>
+                </div>
+              )}
+
+              {/* Command Line */}
+              {processDetails.detailedInfo?.cmdline && (
+                <div className="mb-4 p-4 rounded-lg bg-gray-800/50">
+                  <div className="flex items-center gap-2 mb-2">
+                    <FileText className="w-4 h-4 text-green-400" />
+                    <span className="font-medium text-gray-300">Command Line</span>
+                  </div>
+                  <code className="text-sm text-gray-400 break-all">{processDetails.detailedInfo.cmdline}</code>
+                </div>
+              )}
+
+              {/* Ralph Status (if applicable) */}
+              {processDetails.detailedInfo?.ralphStatus && (
+                <div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
+                  <div className="flex items-center gap-2 mb-3">
+                    <Zap className="w-4 h-4 text-yellow-400" />
+                    <span className="font-medium text-yellow-400">Ralph Automation Status</span>
+                  </div>
+                  <div className="grid grid-cols-3 gap-4 text-sm mb-3">
+                    <div>
+                      <div className="text-gray-500">Running</div>
+                      <div className={processDetails.detailedInfo.ralphStatus.running ? 'text-green-400' : 'text-gray-400'}>
+                        {processDetails.detailedInfo.ralphStatus.running ? 'Yes' : 'No'}
+                      </div>
+                    </div>
+                    <div>
+                      <div className="text-gray-500">Current Story</div>
+                      <div className="text-gray-300">{processDetails.detailedInfo.ralphStatus.currentStory || 'N/A'}</div>
+                    </div>
+                    <div>
+                      <div className="text-gray-500">Progress</div>
+                      <div className="text-gray-300">{processDetails.detailedInfo.ralphStatus.progress}</div>
+                    </div>
+                  </div>
+                  {processDetails.detailedInfo.ralphStatus.lastLog && processDetails.detailedInfo.ralphStatus.lastLog.length > 0 && (
+                    <div>
+                      <div className="text-gray-500 text-sm mb-1">Recent Logs</div>
+                      <div className="bg-black/30 rounded p-2 font-mono text-xs">
+                        {processDetails.detailedInfo.ralphStatus.lastLog.map((log, i) => (
+                          <div key={i} className="text-gray-400">{log}</div>
+                        ))}
+                      </div>
+                    </div>
+                  )}
+                </div>
+              )}
+            </div>
+          ) : (
+            <div className="px-4 py-8 text-center text-gray-500">
+              Click "Check Status" to load process details
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Ralph Status */}
+      <div className="grid grid-cols-1 gap-6">
+        <div className="rounded-xl bg-gray-900 border border-gray-800 overflow-hidden">
+          <div className="px-4 py-3 border-b border-gray-800 flex items-center gap-2">
+            <Zap className="w-5 h-5 text-yellow-400" />
+            <h2 className="font-semibold">Ralph Automation</h2>
+            {data?.ralphStatus?.runningStory && (
+              <span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 flex items-center gap-1">
+                <Loader2 className="w-3 h-3 animate-spin" />
+                Running
+              </span>
+            )}
+          </div>
+
+          {data?.ralphStatus ? (
+            <div>
+              {/* Project Info */}
+              <div className="px-4 py-3 border-b border-gray-800 bg-gray-800/30">
+                <div className="flex items-center gap-2 mb-2">
+                  <GitBranch className="w-4 h-4 text-purple-400" />
+                  <span className="font-medium text-purple-400">{data.ralphStatus.project}</span>
+                </div>
+                <div className="text-xs text-gray-500 font-mono truncate">
+                  {data.ralphStatus.branch}
+                </div>
+                <div className="flex items-center gap-4 mt-2 text-sm">
+                  <span className="text-gray-400">
+                    Progress: <span className="text-green-400 font-bold">{data.ralphStatus.progress}</span>
+                  </span>
+                  <span className="text-gray-400">
+                    Elapsed: <span className="text-blue-400">{formatElapsed(data.ralphStatus.elapsed)}</span>
+                  </span>
+                </div>
+              </div>
+
+              {/* Story List */}
+              <div className="divide-y divide-gray-800 max-h-[300px] overflow-y-auto">
+                {data.ralphStatus.storyDetails.map((story) => (
+                  <div
+                    key={story.id}
+                    className={`px-4 py-2 flex items-center gap-3 ${
+                      story.status === 'running' ? 'bg-yellow-500/10' : ''
+                    }`}
+                  >
+                    <StoryStatusIcon status={story.status} />
+                    <div className="flex-1 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <span className="font-mono text-sm text-gray-300">{story.id}</span>
+                        {story.status === 'running' && (
+                          <span className="text-xs text-yellow-400">← Current</span>
+                        )}
+                      </div>
+                      <div className="text-xs text-gray-500 truncate">{story.title}</div>
+                    </div>
+                    {story.time && (
+                      <span className="text-xs text-gray-500">{formatElapsed(story.time)}</span>
+                    )}
+                  </div>
+                ))}
+              </div>
+
+              {/* Logs - Detailed View */}
+              <div className="border-t border-gray-800">
+                <div className="px-4 py-2 text-xs text-gray-500 bg-gray-800/30 flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <span>Live Logs</span>
+                    <span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
+                      <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
+                      LIVE
+                    </span>
+                  </div>
+                  <span className="text-gray-600">{data.ralphStatus.logs.length} lines</span>
+                </div>
+                <div className="px-4 py-2 min-h-[300px] max-h-[500px] overflow-y-auto bg-black/50 font-mono text-xs">
+                  {data.ralphStatus.logs.map((log, i) => {
+                    const isSuccess = log.includes('✓') || log.includes('Completed') || log.includes('PASS') || log.includes('success')
+                    const isError = log.includes('Error') || log.includes('Failed') || log.includes('FAIL') || log.includes('❌')
+                    const isWarning = log.includes('Warning') || log.includes('⚠')
+                    const isRunning = log.includes('Running') || log.includes('Starting') || log.includes('...')
+                    const isFile = log.includes('.ts') || log.includes('.tsx') || log.includes('.js')
+                    const isCommand = log.startsWith('$') || log.startsWith('npm') || log.startsWith('git')
+                    const isStory = log.includes('US-') || log.includes('Story')
+
+                    return (
+                      <div key={i} className={`py-0.5 leading-relaxed ${
+                        isSuccess ? 'text-green-400' :
+                        isError ? 'text-red-400 font-medium' :
+                        isWarning ? 'text-yellow-400' :
+                        isStory ? 'text-purple-400 font-medium' :
+                        isRunning ? 'text-blue-400' :
+                        isFile ? 'text-cyan-400' :
+                        isCommand ? 'text-orange-400' :
+                        'text-gray-400'
+                      }`}>
+                        {log}
+                      </div>
+                    )
+                  })}
+                  {data.ralphStatus.running && (
+                    <div className="flex items-center gap-2 mt-2 text-blue-400">
+                      <span className="inline-block w-2 h-3 bg-blue-400 animate-pulse" />
+                      <span className="animate-pulse">Processing...</span>
+                    </div>
+                  )}
+                  <div ref={logsEndRef} />
+                </div>
+                {/* Legend */}
+                <div className="px-4 py-2 border-t border-gray-800 bg-gray-900/50 flex items-center gap-3 text-xs text-gray-500">
+                  <span>Legend:</span>
+                  <span className="text-green-400">● Success</span>
+                  <span className="text-red-400">● Error</span>
+                  <span className="text-yellow-400">● Warning</span>
+                  <span className="text-purple-400">● Story</span>
+                  <span className="text-blue-400">● Running</span>
+                  <span className="text-cyan-400">● Files</span>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className="px-4 py-8 text-center text-gray-500">
+              No Ralph automation running
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Spacer for fixed bottom bar */}
+      <div className={bottomBarExpanded ? "h-48" : "h-12"} />
+
+      {/* Fixed Bottom Bar - Collapsible All Processes */}
+      <div className="fixed bottom-0 left-0 right-0 bg-gray-900/95 backdrop-blur-sm border-t border-gray-700 z-50">
+        {/* Header - Always visible */}
+        <button
+          onClick={() => setBottomBarExpanded(!bottomBarExpanded)}
+          className="w-full px-4 py-2 flex items-center justify-between hover:bg-gray-800/50 transition-colors"
+        >
+          <div className="flex items-center gap-2 text-xs text-gray-400">
+            <Layers className="w-4 h-4" />
+            <span className="font-medium">All Recent Processes</span>
+            <span className="text-gray-600">
+              ({allProjects.length} projects, {data?.claudeProcesses?.length || 0} claude)
+            </span>
+            {allProjects.some(p => p.status === 'running') && (
+              <span className="flex items-center gap-1 text-yellow-400">
+                <Loader2 className="w-3 h-3 animate-spin" />
+                <span>Active</span>
+              </span>
+            )}
+          </div>
+          <div className="flex items-center gap-2">
+            {bottomBarExpanded ? (
+              <ChevronDown className="w-4 h-4 text-gray-400" />
+            ) : (
+              <ChevronRight className="w-4 h-4 text-gray-400" />
+            )}
+          </div>
+        </button>
+
+        {/* Expanded Content */}
+        {bottomBarExpanded && (
+          <div className="px-4 pb-3 max-h-[300px] overflow-y-auto">
+            {/* Ralphy Boy Projects */}
+            <div className="mb-3">
+              <div className="text-xs text-gray-500 mb-2 flex items-center gap-2">
+                <Zap className="w-3 h-3" />
+                Ralphy Boy Projects
+              </div>
+              <div className="flex gap-2 overflow-x-auto pb-2">
+                {allProjects.length === 0 ? (
+                  <div className="text-sm text-gray-600">No projects tracked</div>
+                ) : (
+                  allProjects.map((proj) => (
+                    <div
+                      key={proj.project}
+                      className={`flex-shrink-0 px-3 py-2 rounded-lg border transition-all ${
+                        proj.status === 'running'
+                          ? 'bg-yellow-900/30 border-yellow-600/50 shadow-lg shadow-yellow-900/20'
+                          : proj.status === 'completed'
+                          ? 'bg-green-900/20 border-green-700/40'
+                          : proj.status === 'failed'
+                          ? 'bg-red-900/20 border-red-700/40'
+                          : 'bg-gray-800/50 border-gray-700/50'
+                      }`}
+                    >
+                      <div className="flex items-center gap-2 mb-1">
+                        {proj.status === 'running' ? (
+                          <Loader2 className="w-4 h-4 text-yellow-400 animate-spin" />
+                        ) : proj.status === 'completed' ? (
+                          <CheckCircle2 className="w-4 h-4 text-green-400" />
+                        ) : proj.status === 'failed' ? (
+                          <XCircle className="w-4 h-4 text-red-400" />
+                        ) : (
+                          <Circle className="w-4 h-4 text-gray-500" />
+                        )}
+                        <span className="font-medium text-sm text-white truncate max-w-[120px]">
+                          {proj.project}
+                        </span>
+                      </div>
+                      <div className="flex items-center gap-2 text-xs">
+                        <span className={`font-mono ${
+                          proj.status === 'running' ? 'text-yellow-300' :
+                          proj.status === 'completed' ? 'text-green-300' :
+                          proj.status === 'failed' ? 'text-red-300' : 'text-gray-400'
+                        }`}>
+                          {proj.progress}
+                        </span>
+                        {proj.status === 'running' && proj.currentStory && (
+                          <span className="text-blue-300 truncate max-w-[80px]">{proj.currentStory}</span>
+                        )}
+                      </div>
+                    </div>
+                  ))
+                )}
+              </div>
+            </div>
+
+            {/* Claude Processes */}
+            <div>
+              <div className="text-xs text-gray-500 mb-2 flex items-center gap-2">
+                <Terminal className="w-3 h-3" />
+                Claude Processes
+              </div>
+              <div className="flex gap-2 overflow-x-auto pb-2">
+                {!data?.claudeProcesses?.length ? (
+                  <div className="text-sm text-gray-600">No claude processes</div>
+                ) : (
+                  data.claudeProcesses.slice(0, 10).map((proc) => (
+                    <div
+                      key={proc.pid}
+                      onClick={() => setSelectedPid(proc.pid)}
+                      className={`flex-shrink-0 px-3 py-2 rounded-lg border cursor-pointer transition-all hover:border-blue-500/50 ${
+                        proc.type === 'ralph'
+                          ? 'bg-yellow-900/20 border-yellow-700/40'
+                          : proc.type === 'interactive'
+                          ? 'bg-blue-900/20 border-blue-700/40'
+                          : proc.type === 'mcp'
+                          ? 'bg-purple-900/20 border-purple-700/40'
+                          : 'bg-gray-800/50 border-gray-700/50'
+                      } ${selectedPid === proc.pid ? 'ring-2 ring-blue-500' : ''}`}
+                    >
+                      <div className="flex items-center gap-2 mb-1">
+                        <ProcessTypeIcon type={proc.type} />
+                        <span className="font-medium text-sm text-white truncate max-w-[100px]">
+                          {proc.project || proc.type}
+                        </span>
+                      </div>
+                      <div className="flex items-center gap-2 text-xs text-gray-400">
+                        <span>PID: {proc.pid}</span>
+                        <span className="text-gray-600">|</span>
+                        <span>{proc.cpu}% CPU</span>
+                        <span className="text-gray-600">|</span>
+                        <span>{proc.uptime}</span>
+                      </div>
+                    </div>
+                  ))
+                )}
+              </div>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..34b9781
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,146 @@
+'use client'
+
+import { useState } from 'react'
+import { useRouter } from 'next/navigation'
+
+export default function LoginPage() {
+  const [username, setUsername] = useState('')
+  const [password, setPassword] = useState('')
+  const [showPassword, setShowPassword] = useState(false)
+  const [error, setError] = useState('')
+  const [isLoading, setIsLoading] = useState(false)
+  const router = useRouter()
+
+  async function handleSubmit(e: React.FormEvent) {
+    e.preventDefault()
+    setError('')
+    setIsLoading(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')
+      }
+    } catch (err) {
+      setError('Connection error. Please try again.')
+      console.error('Login error:', err)
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-4">
+      <div className="w-full max-w-md">
+        {/* Logo/Title */}
+        <div className="text-center mb-8">
+          <div className="text-5xl mb-4">🚀</div>
+          <h1 className="text-3xl font-bold bg-gradient-to-r from-[var(--primary)] to-[var(--secondary)] bg-clip-text text-transparent">
+            Good Question Ralph
+          </h1>
+          <p className="text-[var(--foreground-secondary)] mt-2">
+            PRD to Code Automation
+          </p>
+        </div>
+
+        {/* Login Card */}
+        <div className="card p-6">
+          <h2 className="text-xl font-semibold mb-6 text-center">Admin Login</h2>
+
+          <form onSubmit={handleSubmit} className="space-y-4">
+            {error && (
+              <div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
+                {error}
+              </div>
+            )}
+
+            <div>
+              <label className="block text-sm font-medium mb-2">Username</label>
+              <input
+                type="text"
+                className="input"
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                placeholder="Enter username"
+                required
+                autoFocus
+                disabled={isLoading}
+              />
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium mb-2">Password</label>
+              <div className="relative">
+                <input
+                  type={showPassword ? "text" : "password"}
+                  className="input pr-12"
+                  value={password}
+                  onChange={(e) => setPassword(e.target.value)}
+                  placeholder="Enter password"
+                  required
+                  disabled={isLoading}
+                />
+                <button
+                  type="button"
+                  onClick={() => setShowPassword(!showPassword)}
+                  className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--foreground-secondary)] hover:text-[var(--foreground)] transition-colors"
+                  tabIndex={-1}
+                >
+                  {showPassword ? '🙈' : '👁️'}
+                </button>
+              </div>
+            </div>
+
+            <button
+              type="submit"
+              className="btn-primary w-full py-3"
+              disabled={isLoading}
+            >
+              {isLoading ? (
+                <>
+                  <svg className="w-4 h-4 animate-spin mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                  </svg>
+                  Logging in...
+                </>
+              ) : (
+                '🔐 Login'
+              )}
+            </button>
+          </form>
+
+          <div className="mt-6 pt-6 border-t border-[var(--border)]">
+            <div className="text-xs text-[var(--foreground-tertiary)] text-center space-y-1">
+              <p>Protected by DW-Agents Authentication</p>
+              <p>Server: 45.61.58.125</p>
+            </div>
+          </div>
+        </div>
+
+        {/* Security Info */}
+        <div className="mt-6 text-center text-xs text-[var(--foreground-tertiary)]">
+          <div className="flex items-center justify-center gap-4">
+            <span className="flex items-center gap-1">
+              <span className="text-[var(--success)]">●</span>
+              Encrypted Session
+            </span>
+            <span className="flex items-center gap-1">
+              <span className="text-[var(--success)]">●</span>
+              IP Whitelisting
+            </span>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..ac31452
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,409 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import ProjectSelector from '@/components/ProjectSelector'
+import DirectoryTree from '@/components/DirectoryTree'
+import PRDGeneratorStep from '@/components/PRDGeneratorStep'
+import RalphConverterStep from '@/components/RalphConverterStep'
+import RalphyBoyStep from '@/components/RalphyBoyStep'
+import QuickModeStep from '@/components/QuickModeStep'
+import ProcessStatusBar from '@/components/ProcessStatusBar'
+import RecentItemsFooter, { addRecentItem, RecentItem } from '@/components/RecentItemsFooter'
+import { useAuth } from '@/components/AuthProvider'
+import { SidebarDemo } from '@/components/ui/SidebarDemo'
+import { FolderOpen, ChevronDown, ChevronRight, Activity } from 'lucide-react'
+import Link from 'next/link'
+
+export interface Project {
+  name: string
+  path: string
+  hasClaudeMd: boolean
+}
+
+export interface DirectoryNode {
+  name: string
+  path: string
+  type: 'file' | 'directory'
+  children?: DirectoryNode[]
+}
+
+export interface PRDData {
+  projectName: string
+  featureTitle: string
+  problemStatement: string
+  targetUser: string
+  scope: string
+  priority: string
+  keyFeatures: string
+  nonGoals: string
+  successMetrics: string
+  components: string[]
+  integrations: string
+  techStack: string
+  additionalAnswers: string
+  competitorUrls: string[]
+  appStoreUrls: string[]
+  uploadedFiles: string[]
+  researchOptions: {
+    searchGithub: boolean
+    searchPublicDBs: boolean
+    searchSkills: boolean
+    searchAgents: boolean
+    searchAppStore: boolean
+    searchPlayStore: boolean
+  }
+}
+
+export interface UserStory {
+  id: string
+  title: string
+  description: string
+  acceptanceCriteria: string[]
+  priority: number
+  passes: boolean
+  notes: string
+}
+
+export interface RalphData {
+  project: string
+  branchName: string
+  description: string
+  userStories: UserStory[]
+}
+
+export default function Home() {
+  const { username, logout } = useAuth()
+  const [projects, setProjects] = useState<Project[]>([])
+  const [selectedProject, setSelectedProject] = useState<Project | null>(null)
+  const [directoryTree, setDirectoryTree] = useState<DirectoryNode | null>(null)
+  const [isTreeCollapsed, setIsTreeCollapsed] = useState(true)
+  const [currentStep, setCurrentStep] = useState(0)
+  const [stepStatuses, setStepStatuses] = useState<Record<number, string>>({
+    0: 'active',
+    1: 'pending',
+    2: 'pending',
+    3: 'pending'
+  })
+
+  // Quick Mode State
+  const [quickModeText, setQuickModeText] = useState('')
+  const [isQuickMode, setIsQuickMode] = useState(true)
+
+  // PRD State
+  const [prdData, setPrdData] = useState<PRDData>({
+    projectName: '',
+    featureTitle: '',
+    problemStatement: '',
+    targetUser: 'D',
+    scope: 'B',
+    priority: 'B',
+    keyFeatures: '',
+    nonGoals: '',
+    successMetrics: '',
+    components: [],
+    integrations: '',
+    techStack: 'Next.js 16 + React 19',
+    additionalAnswers: '',
+    competitorUrls: [''],
+    appStoreUrls: [''],
+    uploadedFiles: [],
+    researchOptions: {
+      searchGithub: false,
+      searchPublicDBs: false,
+      searchSkills: false,
+      searchAgents: false,
+      searchAppStore: false,
+      searchPlayStore: false
+    }
+  })
+  const [generatedPRD, setGeneratedPRD] = useState('')
+
+  // Ralph State
+  const [ralphData, setRalphData] = useState<RalphData | null>(null)
+  const [validationResults, setValidationResults] = useState<Record<string, boolean>>({})
+
+  // Ralphy Boy State
+  const [isRunning, setIsRunning] = useState(false)
+  const [currentStoryIndex, setCurrentStoryIndex] = useState(0)
+  const [logs, setLogs] = useState<string[]>([])
+
+  // Load projects on mount
+  useEffect(() => {
+    loadProjects()
+  }, [])
+
+  // Load directory tree when project selected
+  useEffect(() => {
+    if (selectedProject) {
+      loadDirectoryTree(selectedProject.path)
+      const defaultFeatureTitle = `New Feature for ${selectedProject.name}`
+      setPrdData(prev => ({
+        ...prev,
+        projectName: selectedProject.name,
+        featureTitle: defaultFeatureTitle
+      }))
+      // Track as recent item
+      addRecentItem({
+        name: selectedProject.name,
+        path: selectedProject.path,
+        type: 'project'
+      })
+    }
+  }, [selectedProject])
+
+  async function loadProjects() {
+    try {
+      const res = await fetch('/api/projects')
+      const data = await res.json()
+      setProjects(data.projects || [])
+    } catch (err) {
+      console.error('Failed to load projects:', err)
+    }
+  }
+
+  async function loadDirectoryTree(path: string) {
+    try {
+      const res = await fetch(`/api/directory?path=${encodeURIComponent(path)}`)
+      const data = await res.json()
+      setDirectoryTree(data)
+    } catch (err) {
+      console.error('Failed to load directory:', err)
+    }
+  }
+
+  function handleStepChange(step: number) {
+    // Allow navigation to completed steps or current step
+    if (step <= currentStep || stepStatuses[step] === 'completed' || stepStatuses[step] === 'active') {
+      setCurrentStep(step)
+    }
+  }
+
+  function handleStepComplete(step: number) {
+    setStepStatuses(prev => ({
+      ...prev,
+      [step]: 'completed',
+      [step + 1]: step < 3 ? 'active' : prev[step + 1]
+    }))
+    if (step < 3) {
+      setCurrentStep(step + 1)
+    }
+  }
+
+  function handlePRDGenerated(prd: string) {
+    setGeneratedPRD(prd)
+    // Mark both step 0 and step 1 as complete when PRD is generated
+    setStepStatuses(prev => ({
+      ...prev,
+      0: 'completed',
+      1: 'completed',
+      2: 'active'
+    }))
+    setCurrentStep(2) // Go directly to Ralph Converter
+  }
+
+  function handleModeToggle(useQuickMode: boolean) {
+    if (!useQuickMode && quickModeText.trim()) {
+      // Switching to form mode - copy quick mode text to additional notes
+      setPrdData(prev => ({
+        ...prev,
+        additionalAnswers: quickModeText + (prev.additionalAnswers ? '\n\n' + prev.additionalAnswers : '')
+      }))
+    }
+    setIsQuickMode(useQuickMode)
+    setCurrentStep(useQuickMode ? 0 : 1)
+  }
+
+  function handleRalphConverted(data: RalphData) {
+    setRalphData(data)
+    handleStepComplete(2)
+  }
+
+  const stepTitles: Record<number, string> = {
+    0: 'Quick Mode',
+    1: 'PRD Generator',
+    2: 'Ralph Converter',
+    3: 'Ralphy Boy Runner'
+  }
+
+  function handleRecentItemClick(item: RecentItem) {
+    // Find and select the project
+    const project = projects.find(p => p.path === item.path)
+    if (project) {
+      setSelectedProject(project)
+    }
+  }
+
+  return (
+    <SidebarDemo
+      currentStep={currentStep}
+      onStepChange={handleStepChange}
+      onLogout={logout}
+      username={username ?? undefined}
+    >
+      <main className="h-full flex flex-col bg-[var(--background)] overflow-hidden">
+        {/* Top Bar with Project Selector */}
+        <header className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--surface)] px-6 py-3">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-4">
+              <h2 className="text-lg font-semibold text-[var(--foreground)]">
+                {stepTitles[currentStep]}
+              </h2>
+              {stepStatuses[currentStep] === 'completed' && (
+                <span className="text-xs px-2 py-1 rounded-full bg-[var(--success)]/20 text-[var(--success)]">
+                  ✓ Completed
+                </span>
+              )}
+            </div>
+            <div className="flex items-center gap-3">
+              <Link
+                href="/live"
+                className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-colors"
+              >
+                <Activity className="w-4 h-4" />
+                <span className="hidden sm:inline">Live Processes</span>
+                <span className="relative flex h-2 w-2">
+                  <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
+                  <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
+                </span>
+              </Link>
+              <ProjectSelector
+                projects={projects}
+                selectedProject={selectedProject}
+                onSelect={setSelectedProject}
+                onCreateNew={() => {/* TODO */}}
+              />
+            </div>
+          </div>
+
+          {/* Project Banner */}
+          {selectedProject && (
+            <div className="mt-3 flex items-center gap-2 text-sm">
+              <FolderOpen className="w-4 h-4 text-[var(--primary)]" />
+              <span className="font-medium text-[var(--primary)]">{selectedProject.name}</span>
+              <span className="text-[var(--foreground-tertiary)]">|</span>
+              <span className="text-[var(--foreground-tertiary)] text-xs">{selectedProject.path}</span>
+            </div>
+          )}
+        </header>
+
+        {/* Main Content Area */}
+        <div className="flex-1 overflow-auto">
+          <div className="p-6">
+            {/* Directory Navigator (Collapsible) */}
+            <div className="mb-6">
+              <button
+                onClick={() => setIsTreeCollapsed(!isTreeCollapsed)}
+                className="flex items-center gap-2 text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] transition-colors"
+              >
+                {isTreeCollapsed ? (
+                  <ChevronRight className="w-4 h-4" />
+                ) : (
+                  <ChevronDown className="w-4 h-4" />
+                )}
+                <FolderOpen className="w-4 h-4" />
+                <span>Directory Navigator</span>
+                {selectedProject && (
+                  <span className="text-[var(--foreground-tertiary)]">— {selectedProject.name}</span>
+                )}
+              </button>
+
+              {!isTreeCollapsed && (
+                <div className="mt-3 p-4 rounded-lg border border-[var(--border)] bg-[var(--surface)] max-h-64 overflow-y-auto">
+                  {directoryTree ? (
+                    <DirectoryTree
+                      node={directoryTree}
+                      depth={0}
+                      selectedPath={selectedProject?.path || ''}
+                    />
+                  ) : (
+                    <p className="text-[var(--foreground-tertiary)] text-sm">
+                      Select a project to view its directory structure
+                    </p>
+                  )}
+                </div>
+              )}
+            </div>
+
+            {/* Step Content */}
+            <div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6">
+              {/* Mode Toggle for Step 0/1 */}
+              {(currentStep === 0 || currentStep === 1) && (
+                <div className="flex items-center gap-2 mb-6 pb-4 border-b border-[var(--border)]">
+                  <span className="text-sm text-[var(--foreground-secondary)]">Mode:</span>
+                  <button
+                    onClick={() => handleModeToggle(true)}
+                    className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
+                      isQuickMode
+                        ? 'bg-[var(--primary)] text-white'
+                        : 'bg-[var(--surface-alt)] text-[var(--foreground-secondary)] hover:bg-[var(--border)]'
+                    }`}
+                  >
+                    Quick Mode
+                  </button>
+                  <button
+                    onClick={() => handleModeToggle(false)}
+                    className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
+                      !isQuickMode
+                        ? 'bg-[var(--primary)] text-white'
+                        : 'bg-[var(--surface-alt)] text-[var(--foreground-secondary)] hover:bg-[var(--border)]'
+                    }`}
+                  >
+                    Full Form
+                  </button>
+                </div>
+              )}
+
+              {currentStep === 0 && (
+                <QuickModeStep
+                  selectedProject={selectedProject}
+                  quickModeText={quickModeText}
+                  onTextChange={setQuickModeText}
+                  onGenerate={handlePRDGenerated}
+                />
+              )}
+              {currentStep === 1 && (
+                <PRDGeneratorStep
+                  prdData={prdData}
+                  setPrdData={setPrdData}
+                  selectedProject={selectedProject}
+                  onGenerate={handlePRDGenerated}
+                  generatedPRD={generatedPRD}
+                />
+              )}
+              {currentStep === 2 && (
+                <RalphConverterStep
+                  prdMarkdown={generatedPRD}
+                  projectName={selectedProject?.name || ''}
+                  onConvert={handleRalphConverted}
+                  ralphData={ralphData}
+                  validationResults={validationResults}
+                  setValidationResults={setValidationResults}
+                  onBack={() => setCurrentStep(1)}
+                />
+              )}
+              {currentStep === 3 && (
+                <RalphyBoyStep
+                  ralphData={ralphData}
+                  projectPath={selectedProject?.path || ''}
+                  isRunning={isRunning}
+                  setIsRunning={setIsRunning}
+                  currentStoryIndex={currentStoryIndex}
+                  setCurrentStoryIndex={setCurrentStoryIndex}
+                  logs={logs}
+                  setLogs={setLogs}
+                  onBack={() => setCurrentStep(2)}
+                />
+              )}
+            </div>
+          </div>
+        </div>
+
+        {/* Process Status Bar */}
+        <ProcessStatusBar />
+
+        {/* Recent Items Footer */}
+        <RecentItemsFooter onItemClick={handleRecentItemClick} />
+      </main>
+    </SidebarDemo>
+  )
+}
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
new file mode 100644
index 0000000..a22190b
--- /dev/null
+++ b/components/AuthProvider.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
+import { useRouter, usePathname } from 'next/navigation'
+
+interface AuthContextType {
+  isAuthenticated: boolean
+  isLoading: boolean
+  username: string | null
+  logout: () => Promise<void>
+}
+
+const AuthContext = createContext<AuthContextType>({
+  isAuthenticated: false,
+  isLoading: true,
+  username: null,
+  logout: async () => {}
+})
+
+export function useAuth() {
+  return useContext(AuthContext)
+}
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [isAuthenticated, setIsAuthenticated] = useState(false)
+  const [isLoading, setIsLoading] = useState(true)
+  const [username, setUsername] = useState<string | null>(null)
+  const router = useRouter()
+  const pathname = usePathname()
+
+  useEffect(() => {
+    checkAuth()
+  }, [])
+
+  async function checkAuth() {
+    try {
+      const res = await fetch('/api/auth/session')
+      const data = await res.json()
+
+      if (data.authenticated) {
+        setIsAuthenticated(true)
+        setUsername(data.username || 'admin')
+      } else {
+        setIsAuthenticated(false)
+        setUsername(null)
+        // Redirect to login if not on login page
+        if (pathname !== '/login') {
+          router.push('/login')
+        }
+      }
+    } catch (err) {
+      console.error('Auth check failed:', err)
+      setIsAuthenticated(false)
+      if (pathname !== '/login') {
+        router.push('/login')
+      }
+    } finally {
+      setIsLoading(false)
+    }
+  }
+
+  async function logout() {
+    try {
+      await fetch('/api/auth/logout', { method: 'POST' })
+      setIsAuthenticated(false)
+      setUsername(null)
+      router.push('/login')
+    } catch (err) {
+      console.error('Logout error:', err)
+    }
+  }
+
+  // Show loading state
+  if (isLoading) {
+    return (
+      <div className="min-h-screen bg-[var(--background)] flex items-center justify-center">
+        <div className="text-center">
+          <div className="text-4xl mb-4 animate-pulse">🚀</div>
+          <div className="text-[var(--foreground-secondary)]">Loading...</div>
+        </div>
+      </div>
+    )
+  }
+
+  // Allow login page to render without auth
+  if (pathname === '/login') {
+    return <>{children}</>
+  }
+
+  // Require auth for all other pages
+  if (!isAuthenticated) {
+    return null // Will redirect in useEffect
+  }
+
+  return (
+    <AuthContext.Provider value={{ isAuthenticated, isLoading, username, logout }}>
+      {children}
+    </AuthContext.Provider>
+  )
+}
diff --git a/components/CollapsibleSection.tsx b/components/CollapsibleSection.tsx
new file mode 100644
index 0000000..d9f2e62
--- /dev/null
+++ b/components/CollapsibleSection.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+interface Props {
+  title: string
+  icon: string
+  isExpanded: boolean
+  onToggle: () => void
+  children: React.ReactNode
+}
+
+export default function CollapsibleSection({ title, icon, isExpanded, onToggle, children }: Props) {
+  return (
+    <div className="card overflow-hidden">
+      <div
+        className="flex items-center justify-between p-4 cursor-pointer hover:bg-[var(--surface-alt)] transition-colors"
+        onClick={onToggle}
+      >
+        <div className="flex items-center gap-3">
+          <span className="text-xl">{icon}</span>
+          <h3 className="font-medium">{title}</h3>
+        </div>
+        <svg
+          className={`w-5 h-5 text-[var(--foreground-secondary)] transition-transform ${isExpanded ? 'rotate-180' : ''}`}
+          fill="none"
+          stroke="currentColor"
+          viewBox="0 0 24 24"
+        >
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+        </svg>
+      </div>
+
+      {isExpanded && (
+        <div className="px-4 pb-4 pt-2 border-t border-[var(--border)]">
+          {children}
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/components/DirectoryTree.tsx b/components/DirectoryTree.tsx
new file mode 100644
index 0000000..255265c
--- /dev/null
+++ b/components/DirectoryTree.tsx
@@ -0,0 +1,120 @@
+'use client'
+
+import { useState } from 'react'
+
+interface DirectoryNode {
+  name: string
+  path: string
+  type: 'file' | 'directory'
+  children?: DirectoryNode[]
+}
+
+interface Props {
+  node: DirectoryNode
+  depth: number
+  selectedPath: string
+  onSelect?: (path: string) => void
+}
+
+export default function DirectoryTree({ node, depth, selectedPath, onSelect }: Props) {
+  const [isExpanded, setIsExpanded] = useState(depth < 2)
+
+  const isDirectory = node.type === 'directory'
+  const hasChildren = isDirectory && node.children && node.children.length > 0
+  const isSelected = node.path === selectedPath
+
+  const fileIcon = getFileIcon(node.name, isDirectory)
+
+  return (
+    <div>
+      <div
+        className={`tree-item ${isSelected ? 'selected' : ''}`}
+        style={{ paddingLeft: `${depth * 16 + 8}px` }}
+        onClick={() => {
+          if (isDirectory) {
+            setIsExpanded(!isExpanded)
+          }
+          onSelect?.(node.path)
+        }}
+      >
+        {/* Chevron */}
+        <div className={`tree-chevron ${isExpanded && hasChildren ? 'expanded' : ''}`}>
+          {hasChildren ? (
+            <svg className="w-4 h-4 text-[var(--foreground-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
+            </svg>
+          ) : (
+            <span className="w-4" />
+          )}
+        </div>
+
+        {/* Icon */}
+        <span className="text-sm">{fileIcon}</span>
+
+        {/* Name */}
+        <span className="text-sm truncate">{node.name}</span>
+      </div>
+
+      {/* Children */}
+      {isExpanded && hasChildren && (
+        <div>
+          {node.children!
+            .sort((a, b) => {
+              // Directories first, then files
+              if (a.type !== b.type) {
+                return a.type === 'directory' ? -1 : 1
+              }
+              return a.name.localeCompare(b.name)
+            })
+            .map((child) => (
+              <DirectoryTree
+                key={child.path}
+                node={child}
+                depth={depth + 1}
+                selectedPath={selectedPath}
+                onSelect={onSelect}
+              />
+            ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+function getFileIcon(name: string, isDirectory: boolean): string {
+  if (isDirectory) {
+    const specialFolders: Record<string, string> = {
+      'node_modules': '📦',
+      '.git': '🔧',
+      'app': '📱',
+      'components': '🧩',
+      'lib': '📚',
+      'api': '🔌',
+      'public': '🌐',
+      'styles': '🎨',
+      'tests': '🧪',
+      '__tests__': '🧪',
+    }
+    return specialFolders[name] || '📁'
+  }
+
+  const ext = name.split('.').pop()?.toLowerCase() || ''
+  const icons: Record<string, string> = {
+    'ts': '📘',
+    'tsx': '⚛️',
+    'js': '📒',
+    'jsx': '⚛️',
+    'json': '📋',
+    'md': '📝',
+    'css': '🎨',
+    'scss': '🎨',
+    'html': '🌐',
+    'env': '🔐',
+    'gitignore': '🔧',
+    'lock': '🔒',
+    'png': '🖼️',
+    'jpg': '🖼️',
+    'svg': '🎭',
+  }
+  return icons[ext] || '📄'
+}
diff --git a/components/PRDGeneratorStep.tsx b/components/PRDGeneratorStep.tsx
new file mode 100644
index 0000000..bba6ea4
--- /dev/null
+++ b/components/PRDGeneratorStep.tsx
@@ -0,0 +1,1283 @@
+'use client'
+
+import { useState } from 'react'
+import CollapsibleSection from './CollapsibleSection'
+import SimplifiedQuestionnaire from './SimplifiedQuestionnaire'
+
+interface PRDData {
+  projectName: string
+  featureTitle: string
+  problemStatement: string
+  targetUser: string
+  scope: string
+  priority: string
+  keyFeatures: string
+  nonGoals: string
+  successMetrics: string
+  components: string[]
+  integrations: string
+  techStack: string
+  additionalAnswers: string
+  competitorUrls: string[]
+  appStoreUrls: string[]
+  uploadedFiles: string[]
+  researchOptions: {
+    searchGithub: boolean
+    searchPublicDBs: boolean
+    searchSkills: boolean
+    searchAgents: boolean
+    searchAppStore: boolean
+    searchPlayStore: boolean
+  }
+}
+
+interface Project {
+  name: string
+  path: string
+  hasClaudeMd: boolean
+}
+
+interface GitInfo {
+  isRepo: boolean
+  branch: string | null
+  remoteUrl: string | null
+  lastCommit: string | null
+  lastCommitDate: string | null
+  uncommittedChanges: number
+  hasGitignore: boolean
+}
+
+interface SecurityFeatures {
+  hasAuth: boolean
+  hasEnvExample: boolean
+  hasEnvLocal: boolean
+  hasSecrets: boolean
+  authType: string | null
+  hasHttps: boolean
+  hasCors: boolean
+  hasRateLimiting: boolean
+  hasInputValidation: boolean
+  hasCSRF: boolean
+}
+
+interface DetectedAPI {
+  name: string
+  type: 'ai' | 'payment' | 'storage' | 'auth' | 'email' | 'analytics' | 'other'
+  detected: boolean
+  envVar?: string
+}
+
+interface ProjectScanResult {
+  name: string
+  path: string
+  description: string
+  techStack: string[]
+  dependencies: string[]
+  components: string[]
+  existingPRDs: string[]
+  hasReadme: boolean
+  hasClaudeMd: boolean
+  hasPackageJson: boolean
+  git: GitInfo
+  security: SecurityFeatures
+  detectedAPIs: DetectedAPI[]
+  recentFiles: string[]
+  suggestedFeatures: string[]
+}
+
+interface Props {
+  prdData: PRDData
+  setPrdData: (data: PRDData) => void
+  selectedProject: Project | null
+  onGenerate: (prd: string) => void
+  generatedPRD: string
+}
+
+const targetUserOptions = [
+  { value: 'A', label: 'New users', description: 'First-time visitors to the application' },
+  { value: 'B', label: 'Existing users', description: 'Users already familiar with the app' },
+  { value: 'C', label: 'Admin users', description: 'Administrators and power users' },
+  { value: 'D', label: 'All users', description: 'Everyone who uses the application' },
+]
+
+const scopeOptions = [
+  { value: 'A', label: 'Minimal viable', description: 'Core functionality only' },
+  { value: 'B', label: 'Full-featured', description: 'Complete implementation' },
+  { value: 'C', label: 'Backend only', description: 'API and database changes' },
+  { value: 'D', label: 'Frontend only', description: 'UI and UX changes' },
+]
+
+const priorityOptions = [
+  { value: 'A', label: 'Critical/ASAP', description: 'Needs immediate attention' },
+  { value: 'B', label: 'High', description: 'Important for upcoming release' },
+  { value: 'C', label: 'Medium', description: 'Should be done when possible' },
+  { value: 'D', label: 'Low/Nice-to-have', description: 'Can wait for future releases' },
+]
+
+const techStackOptions = [
+  'Next.js 16 + React 19',
+  'React 19 (Standalone)',
+  'Node.js + Express',
+  'Python + FastAPI',
+  'Other',
+]
+
+// Clarifying question from the PRD skill
+interface ClarifyingQuestion {
+  id: string
+  question: string
+  options: { key: string; label: string }[]
+  answer?: string
+}
+
+export default function PRDGeneratorStep({ prdData, setPrdData, selectedProject, onGenerate, generatedPRD }: Props) {
+  const [isGenerating, setIsGenerating] = useState(false)
+  const [isScanning, setIsScanning] = useState(false)
+  const [scanResult, setScanResult] = useState<ProjectScanResult | null>(null)
+  const [showPreview, setShowPreview] = useState(false)
+  const [useSimplifiedMode, setUseSimplifiedMode] = useState(false)
+  const [competitorSuggestion, setCompetitorSuggestion] = useState<string | null>(null)
+  const [appStoreSuggestion, setAppStoreSuggestion] = useState<string | null>(null)
+  const [isLoadingCompetitorSuggestion, setIsLoadingCompetitorSuggestion] = useState(false)
+  const [isLoadingAppStoreSuggestion, setIsLoadingAppStoreSuggestion] = useState(false)
+
+  // New state for skill-based clarifying questions flow
+  const [skillStep, setSkillStep] = useState<'questionnaire' | 'clarifying' | 'generating' | 'preview'>('questionnaire')
+  const [clarifyingQuestions, setClarifyingQuestions] = useState<ClarifyingQuestion[]>([])
+  const [isLoadingQuestions, setIsLoadingQuestions] = useState(false)
+
+  const [expandedSections, setExpandedSections] = useState({
+    foundation: false,
+    targetScope: false,
+    requirements: false,
+    technical: false,
+    inspiration: false,
+    additional: false,
+  })
+
+  // Handle simplified questionnaire completion
+  const handleSimplifiedComplete = async () => {
+    setUseSimplifiedMode(false)
+    // Submit to skill flow instead of directly generating
+    if (prdData.featureTitle && prdData.problemStatement) {
+      submitToSkill()
+    }
+  }
+
+  // Show simplified questionnaire if enabled
+  if (useSimplifiedMode) {
+    return (
+      <SimplifiedQuestionnaire
+        prdData={prdData}
+        setPrdData={setPrdData as React.Dispatch<React.SetStateAction<PRDData>>}
+        selectedProject={selectedProject}
+        onComplete={handleSimplifiedComplete}
+        onCancel={() => setUseSimplifiedMode(false)}
+      />
+    )
+  }
+
+  // Fetch Claude suggestions for competitor URLs
+  async function fetchCompetitorSuggestions() {
+    setIsLoadingCompetitorSuggestion(true)
+    setCompetitorSuggestion(null)
+    try {
+      const res = await fetch('/api/claude/suggest', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          projectName: prdData.projectName || selectedProject?.name,
+          currentField: 'competitorUrls',
+          currentQuestion: 'Competitor or Inspiration Apps',
+          existingData: prdData
+        })
+      })
+      if (res.ok) {
+        const data = await res.json()
+        setCompetitorSuggestion(data.suggestion)
+      }
+    } catch (err) {
+      console.error('Suggestion error:', err)
+      setCompetitorSuggestion('Unable to get suggestions. Please try again.')
+    } finally {
+      setIsLoadingCompetitorSuggestion(false)
+    }
+  }
+
+  // Fetch Claude suggestions for App Store URLs
+  async function fetchAppStoreSuggestions() {
+    setIsLoadingAppStoreSuggestion(true)
+    setAppStoreSuggestion(null)
+    try {
+      const res = await fetch('/api/claude/suggest', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          projectName: prdData.projectName || selectedProject?.name,
+          currentField: 'appStoreUrls',
+          currentQuestion: 'Similar Apps on App Store / Play Store',
+          existingData: prdData
+        })
+      })
+      if (res.ok) {
+        const data = await res.json()
+        setAppStoreSuggestion(data.suggestion)
+      }
+    } catch (err) {
+      console.error('Suggestion error:', err)
+      setAppStoreSuggestion('Unable to get suggestions. Please try again.')
+    } finally {
+      setIsLoadingAppStoreSuggestion(false)
+    }
+  }
+
+  // Submit to PRD skill - gets clarifying questions
+  async function submitToSkill() {
+    if (!prdData.featureTitle || !prdData.problemStatement) {
+      alert('Please fill in Feature Title and Problem Statement')
+      return
+    }
+
+    setIsLoadingQuestions(true)
+    try {
+      const res = await fetch('/api/prd/clarify', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          ...prdData,
+          projectPath: selectedProject?.path
+        })
+      })
+
+      if (!res.ok) {
+        throw new Error('Failed to get clarifying questions')
+      }
+
+      const data = await res.json()
+      if (data.questions && data.questions.length > 0) {
+        setClarifyingQuestions(data.questions)
+        setSkillStep('clarifying')
+      } else {
+        // No questions needed, go straight to generation
+        setSkillStep('generating')
+        await generatePRDWithAnswers([])
+      }
+    } catch (err) {
+      console.error('Failed to get clarifying questions:', err)
+      alert('Failed to submit to PRD skill. Please try again.')
+    } finally {
+      setIsLoadingQuestions(false)
+    }
+  }
+
+  // Handle answering a clarifying question
+  function handleClarifyingAnswer(questionId: string, answer: string) {
+    setClarifyingQuestions(prev =>
+      prev.map(q => q.id === questionId ? { ...q, answer } : q)
+    )
+  }
+
+  // Generate PRD with clarifying question answers
+  async function generatePRDWithAnswers(answers: ClarifyingQuestion[]) {
+    setSkillStep('generating')
+    setIsGenerating(true)
+    try {
+      // Format answers as "1A, 2C, 3B" style
+      const formattedAnswers = answers
+        .filter(q => q.answer)
+        .map((q, i) => `${i + 1}${q.answer}: ${q.question} → ${q.options.find(o => o.key === q.answer)?.label || q.answer}`)
+        .join('\n')
+
+      const res = await fetch('/api/prd/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          ...prdData,
+          additionalAnswers: prdData.additionalAnswers
+            ? `${prdData.additionalAnswers}\n\nClarifying Questions:\n${formattedAnswers}`
+            : `Clarifying Questions:\n${formattedAnswers}`,
+          projectPath: selectedProject?.path
+        })
+      })
+
+      const data = await res.json()
+      if (data.prd) {
+        onGenerate(data.prd)
+        setSkillStep('preview')
+        setShowPreview(true)
+      } else {
+        throw new Error(data.error || 'Failed to generate PRD')
+      }
+    } catch (err) {
+      console.error('Failed to generate PRD:', err)
+      alert('Failed to generate PRD')
+      setSkillStep('clarifying')
+    } finally {
+      setIsGenerating(false)
+    }
+  }
+
+  // Submit clarifying answers and generate PRD
+  function submitClarifyingAnswers() {
+    const unanswered = clarifyingQuestions.filter(q => !q.answer)
+    if (unanswered.length > 0) {
+      alert(`Please answer all questions. ${unanswered.length} remaining.`)
+      return
+    }
+    generatePRDWithAnswers(clarifyingQuestions)
+  }
+
+  // Scan project and auto-fill form fields
+  async function handleScanProject() {
+    if (!selectedProject?.path) {
+      alert('Please select a project first')
+      return
+    }
+
+    setIsScanning(true)
+    try {
+      const res = await fetch(`/api/projects/scan?path=${encodeURIComponent(selectedProject.path)}`)
+
+      if (!res.ok) {
+        const err = await res.json()
+        throw new Error(err.error || 'Failed to scan project')
+      }
+
+      const data: ProjectScanResult = await res.json()
+      setScanResult(data)
+
+      // Auto-fill form fields from scan result
+      const techStackStr = data.techStack.length > 0
+        ? (data.techStack.some(t => t.includes('Next.js')) ? 'Next.js 16 + React 19'
+          : data.techStack.some(t => t.includes('React')) ? 'React 19 (Standalone)'
+          : data.techStack.some(t => t.includes('Express')) ? 'Node.js + Express'
+          : 'Other')
+        : prdData.techStack
+
+      // Build suggested features as key features
+      const suggestedKeyFeatures = data.suggestedFeatures.length > 0
+        ? data.suggestedFeatures.map(f => `• ${f}`).join('\n')
+        : prdData.keyFeatures
+
+      // Build integrations from dependencies
+      const integrations = data.dependencies
+        .filter(d => ['@anthropic-ai/sdk', 'axios', 'prisma', 'next-auth', 'stripe'].some(i => d.includes(i)))
+        .join(', ') || prdData.integrations
+
+      // Generate feature title if not set
+      const featureTitle = prdData.featureTitle || `New Feature for ${data.name}`
+
+      updatePrdData({
+        projectName: data.name,
+        featureTitle: featureTitle,
+        problemStatement: data.description || prdData.problemStatement,
+        techStack: techStackStr,
+        keyFeatures: suggestedKeyFeatures,
+        integrations: integrations,
+        components: data.components.map(c => c.replace('.tsx', '').replace('.jsx', ''))
+      })
+
+      // Open technical and inspiration sections to show filled data and Search Claude buttons
+      setExpandedSections(prev => ({ ...prev, technical: true, inspiration: true }))
+    } catch (err) {
+      console.error('Failed to scan project:', err)
+      alert(err instanceof Error ? err.message : 'Failed to scan project')
+    } finally {
+      setIsScanning(false)
+    }
+  }
+
+  function updatePrdData(updates: Partial<PRDData>) {
+    setPrdData({ ...prdData, ...updates })
+  }
+
+  function toggleSection(section: keyof typeof expandedSections) {
+    setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }))
+  }
+
+  async function handleGenerate() {
+    if (!prdData.featureTitle || !prdData.problemStatement) {
+      alert('Please fill in Feature Title and Problem Statement')
+      return
+    }
+
+    setIsGenerating(true)
+    try {
+      const res = await fetch('/api/prd/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          ...prdData,
+          projectPath: selectedProject?.path
+        })
+      })
+      const data = await res.json()
+      if (data.prd) {
+        onGenerate(data.prd)
+        setShowPreview(true)
+      }
+    } catch (err) {
+      console.error('Failed to generate PRD:', err)
+      alert('Failed to generate PRD')
+    } finally {
+      setIsGenerating(false)
+    }
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Mode Toggle */}
+      <div className="flex items-center justify-between p-4 rounded-xl bg-gradient-to-r from-[var(--primary)]/10 to-[var(--secondary)]/10 border border-[var(--border)]">
+        <div className="flex items-center gap-4">
+          <div>
+            <div className="font-medium">Questionnaire Mode</div>
+            <div className="text-sm text-[var(--foreground-secondary)]">
+              {useSimplifiedMode ? 'One question at a time, large fonts' : 'All questions on one page'}
+            </div>
+          </div>
+        </div>
+        <div className="flex items-center gap-3">
+          <span className={`text-sm ${!useSimplifiedMode ? 'text-[var(--primary)] font-medium' : 'text-[var(--foreground-secondary)]'}`}>
+            Full Form
+          </span>
+          <button
+            onClick={() => setUseSimplifiedMode(!useSimplifiedMode)}
+            className={`relative w-14 h-7 rounded-full transition-colors ${
+              useSimplifiedMode ? 'bg-[var(--primary)]' : 'bg-[var(--surface-alt)]'
+            }`}
+          >
+            <span
+              className={`absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform ${
+                useSimplifiedMode ? 'translate-x-8' : 'translate-x-1'
+              }`}
+            />
+          </button>
+          <span className={`text-sm ${useSimplifiedMode ? 'text-[var(--primary)] font-medium' : 'text-[var(--foreground-secondary)]'}`}>
+            Step-by-Step
+          </span>
+        </div>
+      </div>
+
+      {/* Section A: Project Foundation */}
+      <CollapsibleSection
+        title="Section A: Project Foundation"
+        icon="🏗️"
+        isExpanded={expandedSections.foundation}
+        onToggle={() => toggleSection('foundation')}
+      >
+        <div className="space-y-4">
+          {/* Scan Project Button */}
+          {selectedProject && (
+            <div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)]">
+              <div className="flex items-center gap-3">
+                <span className="text-lg">📂</span>
+                <div>
+                  <div className="font-medium">{selectedProject.name}</div>
+                  <div className="text-xs text-[var(--foreground-tertiary)]">{selectedProject.path}</div>
+                </div>
+              </div>
+              <button
+                onClick={handleScanProject}
+                disabled={isScanning}
+                className="btn-primary text-sm px-4 py-2"
+              >
+                {isScanning ? (
+                  <>
+                    <svg className="w-4 h-4 animate-spin mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                    </svg>
+                    Scanning...
+                  </>
+                ) : (
+                  <>🔍 Scan & Fill Fields</>
+                )}
+              </button>
+            </div>
+          )}
+
+          {/* Scan Result Summary */}
+          {scanResult && (
+            <div className="space-y-3">
+              {/* Basic Info */}
+              <div className="p-3 rounded-lg bg-[var(--success)]/10 border border-[var(--success)]/30">
+                <div className="flex items-center gap-2 mb-2">
+                  <span className="text-[var(--success)]">✓</span>
+                  <span className="font-medium text-[var(--success)]">Project Scanned Successfully</span>
+                </div>
+                <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
+                  <div className="flex items-center gap-1">
+                    <span>{scanResult.hasPackageJson ? '✓' : '✗'}</span>
+                    <span>package.json</span>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <span>{scanResult.hasReadme ? '✓' : '✗'}</span>
+                    <span>README.md</span>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <span>{scanResult.hasClaudeMd ? '✓' : '✗'}</span>
+                    <span>CLAUDE.md</span>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <span>{scanResult.git.isRepo ? '✓' : '✗'}</span>
+                    <span>Git</span>
+                  </div>
+                </div>
+                {scanResult.techStack.length > 0 && (
+                  <div className="mt-2 flex flex-wrap gap-1">
+                    {scanResult.techStack.map((tech, i) => (
+                      <span key={i} className="px-2 py-0.5 text-xs rounded bg-[var(--primary)]/20 text-[var(--primary)]">
+                        {tech}
+                      </span>
+                    ))}
+                  </div>
+                )}
+              </div>
+
+              {/* Git Info */}
+              {scanResult.git.isRepo && (
+                <div className="p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)]">
+                  <div className="flex items-center gap-2 mb-2">
+                    <span>📦</span>
+                    <span className="font-medium text-sm">Git Repository</span>
+                  </div>
+                  <div className="grid grid-cols-2 gap-2 text-xs">
+                    <div>
+                      <span className="text-[var(--foreground-tertiary)]">Branch:</span>{' '}
+                      <span className="text-[var(--primary)]">{scanResult.git.branch}</span>
+                    </div>
+                    {scanResult.git.remoteUrl && (
+                      <div className="truncate">
+                        <span className="text-[var(--foreground-tertiary)]">Remote:</span>{' '}
+                        <span>{scanResult.git.remoteUrl.replace('https://github.com/', '').replace('.git', '')}</span>
+                      </div>
+                    )}
+                    {scanResult.git.lastCommit && (
+                      <div className="col-span-2 truncate">
+                        <span className="text-[var(--foreground-tertiary)]">Last Commit:</span>{' '}
+                        <span>{scanResult.git.lastCommit}</span>
+                      </div>
+                    )}
+                    {scanResult.git.uncommittedChanges > 0 && (
+                      <div>
+                        <span className="text-[var(--warning)]">⚠ {scanResult.git.uncommittedChanges} uncommitted changes</span>
+                      </div>
+                    )}
+                  </div>
+                </div>
+              )}
+
+              {/* Security Features */}
+              <div className="p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)]">
+                <div className="flex items-center gap-2 mb-2">
+                  <span>🔐</span>
+                  <span className="font-medium text-sm">Security Features</span>
+                </div>
+                <div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasAuth} readOnly className="accent-[var(--success)]" />
+                    <span>Authentication {scanResult.security.authType && `(${scanResult.security.authType})`}</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasEnvLocal} readOnly className="accent-[var(--success)]" />
+                    <span>Env Variables</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasEnvExample} readOnly className="accent-[var(--success)]" />
+                    <span>.env.example</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasCors} readOnly className="accent-[var(--success)]" />
+                    <span>CORS Protection</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasRateLimiting} readOnly className="accent-[var(--success)]" />
+                    <span>Rate Limiting</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasInputValidation} readOnly className="accent-[var(--success)]" />
+                    <span>Input Validation</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.security.hasCSRF} readOnly className="accent-[var(--success)]" />
+                    <span>CSRF Protection</span>
+                  </label>
+                  <label className="flex items-center gap-2 cursor-default">
+                    <input type="checkbox" checked={scanResult.git.hasGitignore} readOnly className="accent-[var(--success)]" />
+                    <span>.gitignore</span>
+                  </label>
+                </div>
+                {scanResult.security.hasSecrets && (
+                  <div className="mt-2 text-xs text-[var(--warning)]">
+                    ⚠ Potential hardcoded secrets detected in source files
+                  </div>
+                )}
+              </div>
+
+              {/* Detected APIs */}
+              {scanResult.detectedAPIs.length > 0 && (
+                <div className="p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)]">
+                  <div className="flex items-center gap-2 mb-2">
+                    <span>🔌</span>
+                    <span className="font-medium text-sm">Detected APIs ({scanResult.detectedAPIs.length})</span>
+                  </div>
+                  <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
+                    {scanResult.detectedAPIs.map((api, i) => (
+                      <div key={i} className="flex items-center gap-2 text-xs p-2 rounded bg-[var(--surface)] border border-[var(--border)]">
+                        <span className={`w-2 h-2 rounded-full ${
+                          api.type === 'ai' ? 'bg-purple-500' :
+                          api.type === 'payment' ? 'bg-green-500' :
+                          api.type === 'storage' ? 'bg-blue-500' :
+                          api.type === 'auth' ? 'bg-yellow-500' :
+                          api.type === 'email' ? 'bg-pink-500' :
+                          api.type === 'analytics' ? 'bg-cyan-500' : 'bg-gray-500'
+                        }`} />
+                        <div>
+                          <div className="font-medium">{api.name}</div>
+                          <div className="text-[var(--foreground-tertiary)] text-[10px]">{api.envVar}</div>
+                        </div>
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {scanResult.existingPRDs.length > 0 && (
+                <div className="text-xs text-[var(--foreground-secondary)]">
+                  Existing PRDs: {scanResult.existingPRDs.join(', ')}
+                </div>
+              )}
+            </div>
+          )}
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Project Name</label>
+            <input
+              type="text"
+              className="input"
+              value={prdData.projectName}
+              onChange={(e) => updatePrdData({ projectName: e.target.value })}
+              placeholder={selectedProject?.name || 'Enter project name'}
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Feature Title *</label>
+            <input
+              type="text"
+              className="input"
+              value={prdData.featureTitle}
+              onChange={(e) => updatePrdData({ featureTitle: e.target.value })}
+              placeholder="e.g., Voice Auto-Start Enhancement"
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Problem Statement *</label>
+            <textarea
+              className="textarea"
+              value={prdData.problemStatement}
+              onChange={(e) => updatePrdData({ problemStatement: e.target.value })}
+              placeholder="What problem does this feature solve? Why is it needed?"
+              rows={3}
+            />
+          </div>
+        </div>
+      </CollapsibleSection>
+
+      {/* Section B: Target & Scope */}
+      <CollapsibleSection
+        title="Section B: Target & Scope"
+        icon="🎯"
+        isExpanded={expandedSections.targetScope}
+        onToggle={() => toggleSection('targetScope')}
+      >
+        <div className="space-y-6">
+          {/* Q1: Target User */}
+          <div>
+            <label className="block text-sm font-medium mb-3">
+              Q1: Who is the primary user?
+            </label>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {targetUserOptions.map((opt) => (
+                <div
+                  key={opt.value}
+                  className={`radio-option ${prdData.targetUser === opt.value ? 'selected' : ''}`}
+                  onClick={() => updatePrdData({ targetUser: opt.value })}
+                >
+                  <div className={`radio-dot ${prdData.targetUser === opt.value ? 'selected' : ''}`} />
+                  <div>
+                    <div className="font-medium">{opt.value}) {opt.label}</div>
+                    <div className="text-xs text-[var(--foreground-tertiary)]">{opt.description}</div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+
+          {/* Q2: Scope */}
+          <div>
+            <label className="block text-sm font-medium mb-3">
+              Q2: What is the scope?
+            </label>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {scopeOptions.map((opt) => (
+                <div
+                  key={opt.value}
+                  className={`radio-option ${prdData.scope === opt.value ? 'selected' : ''}`}
+                  onClick={() => updatePrdData({ scope: opt.value })}
+                >
+                  <div className={`radio-dot ${prdData.scope === opt.value ? 'selected' : ''}`} />
+                  <div>
+                    <div className="font-medium">{opt.value}) {opt.label}</div>
+                    <div className="text-xs text-[var(--foreground-tertiary)]">{opt.description}</div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+
+          {/* Q3: Priority */}
+          <div>
+            <label className="block text-sm font-medium mb-3">
+              Q3: Priority level?
+            </label>
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+              {priorityOptions.map((opt) => (
+                <div
+                  key={opt.value}
+                  className={`radio-option ${prdData.priority === opt.value ? 'selected' : ''}`}
+                  onClick={() => updatePrdData({ priority: opt.value })}
+                >
+                  <div className={`radio-dot ${prdData.priority === opt.value ? 'selected' : ''}`} />
+                  <div>
+                    <div className="font-medium">{opt.value}) {opt.label}</div>
+                    <div className="text-xs text-[var(--foreground-tertiary)]">{opt.description}</div>
+                  </div>
+                </div>
+              ))}
+            </div>
+          </div>
+        </div>
+      </CollapsibleSection>
+
+      {/* Section C: Core Requirements */}
+      <CollapsibleSection
+        title="Section C: Core Requirements"
+        icon="📋"
+        isExpanded={expandedSections.requirements}
+        onToggle={() => toggleSection('requirements')}
+      >
+        <div className="space-y-4">
+          <div>
+            <label className="block text-sm font-medium mb-2">Key Features (bullet points)</label>
+            <textarea
+              className="textarea"
+              value={prdData.keyFeatures}
+              onChange={(e) => updatePrdData({ keyFeatures: e.target.value })}
+              placeholder="• Feature 1&#10;• Feature 2&#10;• Feature 3"
+              rows={4}
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Non-Goals (what this should NOT do)</label>
+            <textarea
+              className="textarea"
+              value={prdData.nonGoals}
+              onChange={(e) => updatePrdData({ nonGoals: e.target.value })}
+              placeholder="• Should not affect existing functionality&#10;• Not handling edge case X"
+              rows={3}
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Success Metrics</label>
+            <textarea
+              className="textarea"
+              value={prdData.successMetrics}
+              onChange={(e) => updatePrdData({ successMetrics: e.target.value })}
+              placeholder="• Response time under 2 seconds&#10;• 90% user satisfaction"
+              rows={3}
+            />
+          </div>
+        </div>
+      </CollapsibleSection>
+
+      {/* Section D: Technical Context */}
+      <CollapsibleSection
+        title="Section D: Technical Context"
+        icon="⚙️"
+        isExpanded={expandedSections.technical}
+        onToggle={() => toggleSection('technical')}
+      >
+        <div className="space-y-4">
+          {/* Scanned Components */}
+          {scanResult && scanResult.components.length > 0 && (
+            <div>
+              <label className="block text-sm font-medium mb-2">
+                Existing Components to Reuse ({scanResult.components.length} found)
+              </label>
+              <div className="grid grid-cols-2 md:grid-cols-3 gap-2 p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)]">
+                {scanResult.components.map((comp, i) => {
+                  const compName = comp.replace('.tsx', '').replace('.jsx', '')
+                  const isSelected = prdData.components.includes(compName)
+                  return (
+                    <label
+                      key={i}
+                      className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
+                        isSelected ? 'bg-[var(--primary)]/20' : 'hover:bg-[var(--surface)]'
+                      }`}
+                    >
+                      <input
+                        type="checkbox"
+                        checked={isSelected}
+                        onChange={(e) => {
+                          if (e.target.checked) {
+                            updatePrdData({ components: [...prdData.components, compName] })
+                          } else {
+                            updatePrdData({ components: prdData.components.filter(c => c !== compName) })
+                          }
+                        }}
+                        className="accent-[var(--primary)]"
+                      />
+                      <span className="text-sm">{comp}</span>
+                    </label>
+                  )
+                })}
+              </div>
+            </div>
+          )}
+
+          {/* Recent Files */}
+          {scanResult && scanResult.recentFiles.length > 0 && (
+            <div>
+              <label className="block text-sm font-medium mb-2">Recently Modified Files</label>
+              <div className="p-3 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)] text-xs font-mono max-h-32 overflow-y-auto">
+                {scanResult.recentFiles.map((file, i) => (
+                  <div key={i} className="text-[var(--foreground-secondary)] py-0.5">{file}</div>
+                ))}
+              </div>
+            </div>
+          )}
+
+          {/* Dependencies */}
+          {scanResult && scanResult.dependencies.length > 0 && (
+            <div>
+              <label className="block text-sm font-medium mb-2">Dependencies ({scanResult.dependencies.length})</label>
+              <div className="flex flex-wrap gap-1">
+                {scanResult.dependencies.map((dep, i) => (
+                  <span key={i} className="px-2 py-0.5 text-xs rounded bg-[var(--surface-alt)] border border-[var(--border)]">
+                    {dep}
+                  </span>
+                ))}
+              </div>
+            </div>
+          )}
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Integration Points</label>
+            <input
+              type="text"
+              className="input"
+              value={prdData.integrations}
+              onChange={(e) => updatePrdData({ integrations: e.target.value })}
+              placeholder="e.g., ElevenLabs API, Browser Web Audio API"
+            />
+          </div>
+
+          <div>
+            <label className="block text-sm font-medium mb-2">Tech Stack</label>
+            <select
+              className="input"
+              value={prdData.techStack}
+              onChange={(e) => updatePrdData({ techStack: e.target.value })}
+            >
+              {techStackOptions.map((opt) => (
+                <option key={opt} value={opt}>{opt}</option>
+              ))}
+            </select>
+          </div>
+        </div>
+      </CollapsibleSection>
+
+      {/* Section E: Inspiration & Research */}
+      <CollapsibleSection
+        title="Section E: Inspiration & Research"
+        icon="🔍"
+        isExpanded={expandedSections.inspiration || false}
+        onToggle={() => toggleSection('inspiration')}
+      >
+        <div className="space-y-6">
+          {/* Competitor/Inspiration URLs */}
+          <div>
+            <label className="block text-sm font-medium mb-2">
+              Competitor or Inspiration Apps (URLs)
+            </label>
+            <p className="text-xs text-[var(--foreground-tertiary)] mb-3">
+              Add links to similar apps or websites for reference
+            </p>
+            <div className="space-y-2">
+              {(prdData.competitorUrls || ['']).map((url, index) => (
+                <div key={index} className="flex gap-2">
+                  <input
+                    type="url"
+                    className="input flex-1"
+                    value={url}
+                    onChange={(e) => {
+                      const newUrls = [...(prdData.competitorUrls || [''])]
+                      newUrls[index] = e.target.value
+                      updatePrdData({ competitorUrls: newUrls })
+                    }}
+                    placeholder="https://example.com"
+                  />
+                  {index === (prdData.competitorUrls || ['']).length - 1 ? (
+                    <button
+                      onClick={() => updatePrdData({ competitorUrls: [...(prdData.competitorUrls || ['']), ''] })}
+                      className="px-3 py-2 rounded-lg bg-[var(--primary)]/20 text-[var(--primary)] hover:bg-[var(--primary)]/30 transition-colors"
+                    >
+                      + Add
+                    </button>
+                  ) : (
+                    <button
+                      onClick={() => {
+                        const newUrls = (prdData.competitorUrls || ['']).filter((_, i) => i !== index)
+                        updatePrdData({ competitorUrls: newUrls.length ? newUrls : [''] })
+                      }}
+                      className="px-3 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
+                    >
+                      Remove
+                    </button>
+                  )}
+                </div>
+              ))}
+            </div>
+
+            {/* Search Claude Button */}
+            <button
+              onClick={fetchCompetitorSuggestions}
+              disabled={isLoadingCompetitorSuggestion}
+              className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-500/30 text-purple-300 hover:from-purple-500/30 hover:to-blue-500/30 transition-all disabled:opacity-50"
+            >
+              {isLoadingCompetitorSuggestion ? (
+                <>
+                  <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                  </svg>
+                  <span>Searching...</span>
+                </>
+              ) : (
+                <>
+                  <span>🔍</span>
+                  <span>Search Claude for Suggestions</span>
+                </>
+              )}
+            </button>
+
+            {/* Suggestion Display */}
+            {competitorSuggestion && (
+              <div className="mt-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/30">
+                <div className="flex items-start gap-2">
+                  <span className="text-lg">💡</span>
+                  <div className="flex-1">
+                    <p className="text-sm text-blue-300 whitespace-pre-wrap">{competitorSuggestion}</p>
+                    <button
+                      onClick={() => setCompetitorSuggestion(null)}
+                      className="mt-2 text-xs text-blue-400 hover:underline"
+                    >
+                      Dismiss
+                    </button>
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+
+          {/* App Store URLs */}
+          <div>
+            <label className="block text-sm font-medium mb-2">
+              Similar Apps on App Store / Play Store
+            </label>
+            <p className="text-xs text-[var(--foreground-tertiary)] mb-3">
+              Links to mobile apps for inspiration
+            </p>
+            <div className="space-y-2">
+              {(prdData.appStoreUrls || ['']).map((url, index) => (
+                <div key={index} className="flex gap-2">
+                  <input
+                    type="url"
+                    className="input flex-1"
+                    value={url}
+                    onChange={(e) => {
+                      const newUrls = [...(prdData.appStoreUrls || [''])]
+                      newUrls[index] = e.target.value
+                      updatePrdData({ appStoreUrls: newUrls })
+                    }}
+                    placeholder="https://apps.apple.com/... or https://play.google.com/..."
+                  />
+                  {index === (prdData.appStoreUrls || ['']).length - 1 ? (
+                    <button
+                      onClick={() => updatePrdData({ appStoreUrls: [...(prdData.appStoreUrls || ['']), ''] })}
+                      className="px-3 py-2 rounded-lg bg-[var(--primary)]/20 text-[var(--primary)] hover:bg-[var(--primary)]/30 transition-colors"
+                    >
+                      + Add
+                    </button>
+                  ) : (
+                    <button
+                      onClick={() => {
+                        const newUrls = (prdData.appStoreUrls || ['']).filter((_, i) => i !== index)
+                        updatePrdData({ appStoreUrls: newUrls.length ? newUrls : [''] })
+                      }}
+                      className="px-3 py-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
+                    >
+                      Remove
+                    </button>
+                  )}
+                </div>
+              ))}
+            </div>
+
+            {/* Search Claude Button for App Store */}
+            <button
+              onClick={fetchAppStoreSuggestions}
+              disabled={isLoadingAppStoreSuggestion}
+              className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-purple-500/20 to-blue-500/20 border border-purple-500/30 text-purple-300 hover:from-purple-500/30 hover:to-blue-500/30 transition-all disabled:opacity-50"
+            >
+              {isLoadingAppStoreSuggestion ? (
+                <>
+                  <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                  </svg>
+                  <span>Searching...</span>
+                </>
+              ) : (
+                <>
+                  <span>🔍</span>
+                  <span>Search Claude for App Suggestions</span>
+                </>
+              )}
+            </button>
+
+            {/* App Store Suggestion Display */}
+            {appStoreSuggestion && (
+              <div className="mt-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/30">
+                <div className="flex items-start gap-2">
+                  <span className="text-lg">💡</span>
+                  <div className="flex-1">
+                    <p className="text-sm text-blue-300 whitespace-pre-wrap">{appStoreSuggestion}</p>
+                    <button
+                      onClick={() => setAppStoreSuggestion(null)}
+                      className="mt-2 text-xs text-blue-400 hover:underline"
+                    >
+                      Dismiss
+                    </button>
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+
+          {/* Research Options */}
+          <div>
+            <label className="block text-sm font-medium mb-2">
+              Research Options
+            </label>
+            <p className="text-xs text-[var(--foreground-tertiary)] mb-3">
+              Select what Claude should research while generating the PRD
+            </p>
+            <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
+              {[
+                { key: 'searchGithub', label: 'Search GitHub', icon: '📦', desc: 'Similar projects & code' },
+                { key: 'searchPublicDBs', label: 'Public Databases', icon: '🗄️', desc: 'APIs & datasets' },
+                { key: 'searchSkills', label: 'Skills Registry', icon: '🎯', desc: 'Existing skills to reuse' },
+                { key: 'searchAgents', label: 'Agent Library', icon: '🤖', desc: 'Pre-built agents' },
+                { key: 'searchAppStore', label: 'App Store', icon: '🍎', desc: 'iOS app inspiration' },
+                { key: 'searchPlayStore', label: 'Play Store', icon: '🤖', desc: 'Android app inspiration' },
+              ].map(({ key, label, icon, desc }) => (
+                <label
+                  key={key}
+                  className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-colors border ${
+                    prdData.researchOptions?.[key as keyof typeof prdData.researchOptions]
+                      ? 'bg-[var(--primary)]/10 border-[var(--primary)]/50'
+                      : 'bg-[var(--surface-alt)] border-[var(--border)] hover:border-[var(--primary)]/30'
+                  }`}
+                >
+                  <input
+                    type="checkbox"
+                    checked={prdData.researchOptions?.[key as keyof typeof prdData.researchOptions] || false}
+                    onChange={(e) => {
+                      updatePrdData({
+                        researchOptions: {
+                          ...prdData.researchOptions,
+                          [key]: e.target.checked
+                        }
+                      })
+                    }}
+                    className="mt-1 accent-[var(--primary)]"
+                  />
+                  <div>
+                    <div className="flex items-center gap-1.5 font-medium text-sm">
+                      <span>{icon}</span>
+                      <span>{label}</span>
+                    </div>
+                    <div className="text-xs text-[var(--foreground-tertiary)]">{desc}</div>
+                  </div>
+                </label>
+              ))}
+            </div>
+          </div>
+        </div>
+      </CollapsibleSection>
+
+      {/* Section F: Additional Questions */}
+      <CollapsibleSection
+        title="Section F: Additional Questions"
+        icon="❓"
+        isExpanded={expandedSections.additional}
+        onToggle={() => toggleSection('additional')}
+      >
+        <div>
+          <label className="block text-sm font-medium mb-2">
+            Additional Notes or Questions (Optional)
+          </label>
+          <textarea
+            className="textarea"
+            value={prdData.additionalAnswers}
+            onChange={(e) => updatePrdData({ additionalAnswers: e.target.value })}
+            placeholder="Any additional context, constraints, or questions..."
+            rows={4}
+          />
+        </div>
+      </CollapsibleSection>
+
+      {/* Actions - Only show in questionnaire step */}
+      {skillStep === 'questionnaire' && (
+        <div className="flex items-center justify-between p-4 card">
+          <div className="text-sm text-[var(--foreground-secondary)]">
+            Answers: 1{prdData.targetUser}, 2{prdData.scope}, 3{prdData.priority}
+          </div>
+          <div className="flex items-center gap-3">
+            <button
+              onClick={submitToSkill}
+              className="btn-primary"
+              disabled={isLoadingQuestions}
+            >
+              {isLoadingQuestions ? (
+                <>
+                  <svg className="w-4 h-4 animate-spin mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                  </svg>
+                  Submitting...
+                </>
+              ) : (
+                <>📄 Submit to PRD Generator</>
+              )}
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* Clarifying Questions Step */}
+      {skillStep === 'clarifying' && (
+        <div className="card p-6">
+          <div className="mb-6">
+            <h3 className="text-xl font-semibold flex items-center gap-2 mb-2">
+              <span>🤔</span>
+              Clarifying Questions
+            </h3>
+            <p className="text-[var(--foreground-secondary)] text-sm">
+              Based on your input, please answer these questions to help generate a better PRD.
+              Select the best option for each (A, B, C, or D).
+            </p>
+          </div>
+
+          <div className="space-y-8">
+            {clarifyingQuestions.map((q, index) => (
+              <div key={q.id} className="border-b border-[var(--border)] pb-6 last:border-b-0 last:pb-0">
+                <div className="font-bold text-lg mb-4">
+                  Q{index + 1}: {q.question}
+                </div>
+                <div className="space-y-2 pl-4">
+                  {q.options.map((opt) => (
+                    <button
+                      key={opt.key}
+                      onClick={() => handleClarifyingAnswer(q.id, opt.key)}
+                      className={`w-full p-3 rounded-lg border text-left transition-all flex items-start gap-3 ${
+                        q.answer === opt.key
+                          ? 'bg-[var(--primary)]/20 border-[var(--primary)]'
+                          : 'bg-[var(--surface)] border-[var(--border)] hover:border-[var(--primary)]/50'
+                      }`}
+                    >
+                      <span className={`font-bold min-w-[24px] ${q.answer === opt.key ? 'text-[var(--primary)]' : ''}`}>
+                        {opt.key})
+                      </span>
+                      <span className="text-[var(--foreground-secondary)]">{opt.label}</span>
+                    </button>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+
+          <div className="flex items-center justify-between mt-6 pt-4 border-t border-[var(--border)]">
+            <button
+              onClick={() => setSkillStep('questionnaire')}
+              className="btn-secondary"
+            >
+              ← Back to Questionnaire
+            </button>
+            <div className="flex items-center gap-2 text-sm text-[var(--foreground-secondary)]">
+              <span>
+                {clarifyingQuestions.filter(q => q.answer).length}/{clarifyingQuestions.length} answered
+              </span>
+            </div>
+            <button
+              onClick={submitClarifyingAnswers}
+              className="btn-primary"
+              disabled={clarifyingQuestions.some(q => !q.answer)}
+            >
+              Generate PRD →
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* Generating Step */}
+      {skillStep === 'generating' && (
+        <div className="card p-8 text-center">
+          <div className="flex flex-col items-center gap-4">
+            <svg className="w-12 h-12 animate-spin text-[var(--primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+            </svg>
+            <div className="text-xl font-medium">Generating PRD...</div>
+            <p className="text-[var(--foreground-secondary)]">
+              Claude is creating your Product Requirements Document based on your inputs.
+            </p>
+          </div>
+        </div>
+      )}
+
+      {/* Preview - Only show after PRD is generated */}
+      {skillStep === 'preview' && generatedPRD && (
+        <div className="card p-4">
+          <div className="flex items-center justify-between mb-4">
+            <h3 className="font-medium flex items-center gap-2">
+              <span>📄</span>
+              Live Preview
+            </h3>
+            <button
+              onClick={() => navigator.clipboard.writeText(generatedPRD)}
+              className="text-sm text-[var(--primary)] hover:underline"
+            >
+              Copy to Clipboard
+            </button>
+          </div>
+          <div className="code-block max-h-96 overflow-y-auto text-sm">
+            <pre>{generatedPRD}</pre>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/components/ProcessStatusBar.tsx b/components/ProcessStatusBar.tsx
new file mode 100644
index 0000000..5f95575
--- /dev/null
+++ b/components/ProcessStatusBar.tsx
@@ -0,0 +1,788 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+
+interface RalphProcess {
+  project: string
+  projectPath: string
+  pid: number
+  cpu: string
+  memory: string
+  status: 'running' | 'idle' | 'completed' | 'failed' | 'paused'
+  currentStory: string
+  startTime: string
+  progress: string
+  errorLogs?: string[]
+  port?: number
+}
+
+// Project port mappings
+const PROJECT_PORTS: Record<string, number> = {
+  'portfolio': 7899,
+  'letsbegin': 7300,
+  'test': 7300,
+  'angels-flowers': 3000,
+  'watches': 3000,
+  'dear-bubbe-nextjs': 3011,
+  'bubbe-ai': 3011,
+}
+
+interface ProcessDetails {
+  project: string
+  projectPath: string
+  status: string
+  logs: string[]
+  stories: { id: string; title: string; status: string; notes?: string }[]
+  progress: string
+  startTime: string
+  pid: number
+  cpu: string
+  memory: string
+  port: number | null
+}
+
+export default function ProcessStatusBar() {
+  const [processes, setProcesses] = useState<RalphProcess[]>([])
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
+  const [loadingAction, setLoadingAction] = useState<string | null>(null)
+  const [errorModal, setErrorModal] = useState<{ project: string; projectPath: string; analysis: string; isFixing?: boolean; fixAttempt?: number } | null>(null)
+  const [detailModal, setDetailModal] = useState<ProcessDetails | null>(null)
+  const [loadingDetails, setLoadingDetails] = useState(false)
+
+  useEffect(() => {
+    fetchProcesses()
+    const interval = setInterval(fetchProcesses, 3000) // Update every 3 seconds
+    return () => clearInterval(interval)
+  }, [])
+
+  async function fetchProcesses() {
+    try {
+      const res = await fetch('/api/processes')
+      if (res.ok) {
+        const data = await res.json()
+        setProcesses(data.processes || [])
+        setLastUpdate(new Date())
+      }
+    } catch (err) {
+      console.error('Failed to fetch processes:', err)
+    }
+  }
+
+  async function handleStart(projectPath: string, project: string) {
+    setLoadingAction(`start-${project}`)
+    try {
+      const res = await fetch('/api/processes/start', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, project })
+      })
+      if (res.ok) {
+        fetchProcesses()
+      } else {
+        const data = await res.json()
+        alert(`Failed to start: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to start process:', err)
+      alert('Failed to start process')
+    } finally {
+      setLoadingAction(null)
+    }
+  }
+
+  async function handleStop(projectPath: string, project: string, pid: number) {
+    setLoadingAction(`stop-${project}`)
+    try {
+      const res = await fetch('/api/ralph/control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ action: 'stop', project, pid })
+      })
+      if (res.ok) {
+        fetchProcesses()
+      } else {
+        const data = await res.json()
+        alert(`Failed to stop: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to stop process:', err)
+      alert('Failed to stop process')
+    } finally {
+      setLoadingAction(null)
+    }
+  }
+
+  async function handlePause(project: string, pid: number) {
+    setLoadingAction(`pause-${project}`)
+    try {
+      const res = await fetch('/api/ralph/control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ action: 'pause', project, pid })
+      })
+      if (res.ok) {
+        fetchProcesses()
+      } else {
+        const data = await res.json()
+        alert(`Failed to pause: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to pause process:', err)
+      alert('Failed to pause process')
+    } finally {
+      setLoadingAction(null)
+    }
+  }
+
+  async function handleResume(project: string, pid: number) {
+    setLoadingAction(`resume-${project}`)
+    try {
+      const res = await fetch('/api/ralph/control', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ action: 'resume', project, pid })
+      })
+      if (res.ok) {
+        fetchProcesses()
+      } else {
+        const data = await res.json()
+        alert(`Failed to resume: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to resume process:', err)
+      alert('Failed to resume process')
+    } finally {
+      setLoadingAction(null)
+    }
+  }
+
+  async function handleAnalyzeErrors(projectPath: string, project: string) {
+    setLoadingAction(`analyze-${project}`)
+    try {
+      const res = await fetch('/api/processes/analyze', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, project })
+      })
+      const data = await res.json()
+      if (res.ok) {
+        setErrorModal({ project, projectPath, analysis: data.analysis, fixAttempt: 0 })
+      } else {
+        alert(`Failed to analyze: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to analyze errors:', err)
+      alert('Failed to analyze errors')
+    } finally {
+      setLoadingAction(null)
+    }
+  }
+
+  async function handleFixAndRestart(projectPath: string, project: string, analysis: string, attempt: number = 1) {
+    if (!errorModal) return
+
+    setErrorModal(prev => prev ? { ...prev, isFixing: true, fixAttempt: attempt } : null)
+
+    try {
+      const res = await fetch('/api/processes/fix', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath, project, analysis, attempt })
+      })
+      const data = await res.json()
+
+      // Update modal with fix results
+      const newAnalysis = `${analysis}\n\n${'='.repeat(50)}\nFIX ATTEMPT ${attempt}\n${'='.repeat(50)}\n${data.logs?.join('\n') || 'No logs'}`
+
+      if (data.success && data.restarted) {
+        setErrorModal(prev => prev ? {
+          ...prev,
+          analysis: newAnalysis + '\n\n✅ Fixes applied and process restarted!',
+          isFixing: false
+        } : null)
+
+        // Wait and check if process is still failing
+        setTimeout(async () => {
+          await fetchProcesses()
+          const proc = processes.find(p => p.project === project)
+          if (proc?.status === 'failed' && attempt < 5) {
+            // Auto-retry if still failing
+            handleFixAndRestart(projectPath, project, newAnalysis, attempt + 1)
+          }
+        }, 10000) // Check after 10 seconds
+      } else {
+        setErrorModal(prev => prev ? {
+          ...prev,
+          analysis: newAnalysis + (data.error ? `\n\n❌ Error: ${data.error}` : ''),
+          isFixing: false
+        } : null)
+      }
+    } catch (err: any) {
+      setErrorModal(prev => prev ? {
+        ...prev,
+        analysis: `${analysis}\n\n❌ Fix failed: ${err.message}`,
+        isFixing: false
+      } : null)
+    }
+  }
+
+  async function openProcessDetails(proc: RalphProcess) {
+    setLoadingDetails(true)
+    try {
+      const res = await fetch(`/api/processes/details?project=${encodeURIComponent(proc.project)}&path=${encodeURIComponent(proc.projectPath)}`)
+      const data = await res.json()
+      if (res.ok) {
+        setDetailModal({
+          project: proc.project,
+          projectPath: proc.projectPath,
+          status: proc.status,
+          logs: data.logs || [],
+          stories: data.stories || [],
+          progress: proc.progress,
+          startTime: proc.startTime,
+          pid: proc.pid,
+          cpu: proc.cpu,
+          memory: proc.memory,
+          port: data.port || null
+        })
+      } else {
+        alert(`Failed to load details: ${data.error || 'Unknown error'}`)
+      }
+    } catch (err) {
+      console.error('Failed to load process details:', err)
+      alert('Failed to load process details')
+    } finally {
+      setLoadingDetails(false)
+    }
+  }
+
+  const runningCount = processes.filter(p => p.status === 'running').length
+  const pausedCount = processes.filter(p => p.status === 'paused').length
+  const completedCount = processes.filter(p => p.status === 'completed').length
+  const failedCount = processes.filter(p => p.status === 'failed').length
+
+  // If no processes, show minimal bar
+  if (processes.length === 0) {
+    return (
+      <div className="fixed bottom-0 left-0 right-0 z-50">
+        <div className="bg-[#0d0d12] border-t-4 border-[var(--border)] px-4 py-2 flex items-center justify-center">
+          <span className="text-sm text-[var(--foreground-tertiary)]">
+            🤖 No Ralphy processes running
+          </span>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="fixed bottom-0 left-0 right-0 z-50">
+      {/* Expanded Panel */}
+      {isExpanded && (
+        <div className="bg-[var(--surface)] border-t-2 border-[var(--primary)] max-h-64 overflow-y-auto">
+          <div className="p-4">
+            <div className="flex justify-between items-center mb-3">
+              <h3 className="font-bold">🤖 Ralphy Boy Processes</h3>
+              <span className="text-xs text-[var(--foreground-tertiary)]">
+                Updated: {lastUpdate.toLocaleTimeString()}
+              </span>
+            </div>
+            <div className="overflow-x-auto">
+              <table className="w-full text-sm">
+                <thead>
+                  <tr className="text-left text-[var(--foreground-secondary)] border-b border-[var(--border)]">
+                    <th className="pb-2 pr-4">Project</th>
+                    <th className="pb-2 pr-4">Status</th>
+                    <th className="pb-2 pr-4">Current Story</th>
+                    <th className="pb-2 pr-4">Progress</th>
+                    <th className="pb-2 pr-4">CPU</th>
+                    <th className="pb-2 pr-4">Started</th>
+                    <th className="pb-2">Actions</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {processes.map((proc, idx) => (
+                    <tr
+                      key={idx}
+                      className="border-b border-[var(--border)]/50 hover:bg-[var(--surface-alt)] cursor-pointer"
+                      onClick={() => openProcessDetails(proc)}
+                    >
+                      <td className="py-2 pr-4 font-medium">
+                        <div className="flex items-center gap-2">
+                          <span className={`w-2 h-2 rounded-full ${
+                            proc.status === 'running' ? 'bg-green-500 animate-pulse' :
+                            proc.status === 'paused' ? 'bg-yellow-500' :
+                            proc.status === 'completed' ? 'bg-blue-500' :
+                            proc.status === 'failed' ? 'bg-red-500' : 'bg-gray-500'
+                          }`}></span>
+                          <span>{proc.project}</span>
+                          <button
+                            onClick={(e) => {
+                              e.stopPropagation()
+                              const port = proc.port || PROJECT_PORTS[proc.project.toLowerCase()] || 3000
+                              window.open(`http://45.61.58.125:${port}/`, '_blank')
+                            }}
+                            className="ml-1 text-[var(--primary)] hover:text-[var(--primary-hover)] transition-colors"
+                            title={`Open http://45.61.58.125:${proc.port || PROJECT_PORTS[proc.project.toLowerCase()] || 3000}/`}
+                          >
+                            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
+                            </svg>
+                          </button>
+                        </div>
+                      </td>
+                      <td className="py-2 pr-4">
+                        <span className={`px-2 py-0.5 rounded text-xs font-medium ${
+                          proc.status === 'running' ? 'bg-green-500/20 text-green-400' :
+                          proc.status === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
+                          proc.status === 'completed' ? 'bg-blue-500/20 text-blue-400' :
+                          proc.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'
+                        }`}>
+                          {proc.status.toUpperCase()}
+                        </span>
+                      </td>
+                      <td className="py-2 pr-4 font-mono text-[var(--primary)]">{proc.currentStory}</td>
+                      <td className="py-2 pr-4">
+                        <div className="flex items-center gap-2">
+                          <div className="w-20 h-2 bg-[var(--surface-alt)] rounded-full overflow-hidden">
+                            <div
+                              className={`h-full ${
+                                proc.status === 'completed' ? 'bg-blue-500' :
+                                proc.status === 'failed' ? 'bg-red-500' : 'bg-green-500'
+                              }`}
+                              style={{
+                                width: `${(() => {
+                                  const [done, total] = proc.progress.split('/').map(Number)
+                                  return total > 0 ? (done / total) * 100 : 0
+                                })()}%`
+                              }}
+                            ></div>
+                          </div>
+                          <span className="text-xs">{proc.progress}</span>
+                        </div>
+                      </td>
+                      <td className="py-2 pr-4 text-[var(--foreground-secondary)]">{proc.cpu}%</td>
+                      <td className="py-2 pr-4 text-[var(--foreground-secondary)]">{proc.startTime}</td>
+                      <td className="py-2">
+                        <div className="flex items-center gap-1">
+                          {proc.status === 'running' ? (
+                            <>
+                              <button
+                                onClick={(e) => { e.stopPropagation(); handlePause(proc.project, proc.pid); }}
+                                disabled={loadingAction === `pause-${proc.project}`}
+                                className="px-2 py-1 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 border border-yellow-500/40 disabled:opacity-50"
+                                title="Pause process"
+                              >
+                                {loadingAction === `pause-${proc.project}` ? '...' : '⏸'}
+                              </button>
+                              <button
+                                onClick={(e) => { e.stopPropagation(); handleStop(proc.projectPath, proc.project, proc.pid); }}
+                                disabled={loadingAction === `stop-${proc.project}`}
+                                className="px-2 py-1 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/40 disabled:opacity-50"
+                                title="Stop process"
+                              >
+                                {loadingAction === `stop-${proc.project}` ? '...' : '⏹'}
+                              </button>
+                            </>
+                          ) : proc.status === 'paused' ? (
+                            <>
+                              <button
+                                onClick={(e) => { e.stopPropagation(); handleResume(proc.project, proc.pid); }}
+                                disabled={loadingAction === `resume-${proc.project}`}
+                                className="px-2 py-1 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 border border-green-500/40 disabled:opacity-50"
+                                title="Resume process"
+                              >
+                                {loadingAction === `resume-${proc.project}` ? '...' : '▶'}
+                              </button>
+                              <button
+                                onClick={(e) => { e.stopPropagation(); handleStop(proc.projectPath, proc.project, proc.pid); }}
+                                disabled={loadingAction === `stop-${proc.project}`}
+                                className="px-2 py-1 text-xs font-medium rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/40 disabled:opacity-50"
+                                title="Stop process"
+                              >
+                                {loadingAction === `stop-${proc.project}` ? '...' : '⏹'}
+                              </button>
+                            </>
+                          ) : (
+                            <button
+                              onClick={(e) => { e.stopPropagation(); handleStart(proc.projectPath, proc.project); }}
+                              disabled={loadingAction === `start-${proc.project}`}
+                              className="px-2 py-1 text-xs font-medium rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 border border-green-500/40 disabled:opacity-50"
+                              title="Start process"
+                            >
+                              {loadingAction === `start-${proc.project}` ? '...' : '▶ Start'}
+                            </button>
+                          )}
+                          {proc.status === 'failed' && (
+                            <button
+                              onClick={(e) => { e.stopPropagation(); handleAnalyzeErrors(proc.projectPath, proc.project); }}
+                              disabled={loadingAction === `analyze-${proc.project}`}
+                              className="px-2 py-1 text-xs font-medium rounded bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30 border border-yellow-500/40 disabled:opacity-50"
+                              title="Analyze errors"
+                            >
+                              {loadingAction === `analyze-${proc.project}` ? '...' : '🔍'}
+                            </button>
+                          )}
+                        </div>
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Status Bar - Thick Line */}
+      <div
+        className="bg-[#0d0d12] border-t-4 border-[var(--primary)] px-4 py-3 flex items-center justify-between cursor-pointer hover:bg-[var(--surface)]"
+        onClick={() => setIsExpanded(!isExpanded)}
+      >
+        <div className="flex items-center gap-4">
+          <div className="flex items-center gap-2">
+            <span className="text-xl">🤖</span>
+            <span className="font-bold">Ralphy Processes</span>
+          </div>
+
+          {/* Process Pills */}
+          <div className="flex items-center gap-2">
+            {processes.map((proc, idx) => (
+              <div
+                key={idx}
+                className={`px-3 py-1.5 rounded-lg text-sm font-medium flex items-center gap-2 ${
+                  proc.status === 'running'
+                    ? 'bg-green-500/20 text-green-400 border border-green-500/40'
+                    : proc.status === 'paused'
+                    ? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/40'
+                    : proc.status === 'completed'
+                    ? 'bg-blue-500/20 text-blue-400 border border-blue-500/40'
+                    : proc.status === 'failed'
+                    ? 'bg-red-500/20 text-red-400 border border-red-500/40'
+                    : 'bg-gray-500/20 text-gray-400 border border-gray-500/40'
+                }`}
+              >
+                <span className={`w-2 h-2 rounded-full ${
+                  proc.status === 'running' ? 'bg-green-500 animate-pulse' :
+                  proc.status === 'paused' ? 'bg-yellow-500' :
+                  proc.status === 'completed' ? 'bg-blue-500' :
+                  proc.status === 'failed' ? 'bg-red-500' : 'bg-gray-500'
+                }`}></span>
+                <span className="font-semibold">{proc.project}</span>
+                <span className="text-xs opacity-75">{proc.currentStory}</span>
+                <span className="text-xs opacity-50">{proc.progress}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        <div className="flex items-center gap-4">
+          {/* Summary Stats */}
+          <div className="flex items-center gap-3 text-sm">
+            {runningCount > 0 && (
+              <div className="flex items-center gap-1">
+                <span className="text-green-400 animate-pulse">●</span>
+                <span className="text-green-400 font-medium">{runningCount} Running</span>
+              </div>
+            )}
+            {pausedCount > 0 && (
+              <div className="flex items-center gap-1">
+                <span className="text-yellow-400">●</span>
+                <span className="text-yellow-400">{pausedCount} Paused</span>
+              </div>
+            )}
+            {completedCount > 0 && (
+              <div className="flex items-center gap-1">
+                <span className="text-blue-400">●</span>
+                <span className="text-blue-400">{completedCount} Done</span>
+              </div>
+            )}
+            {failedCount > 0 && (
+              <div className="flex items-center gap-1">
+                <span className="text-red-400">●</span>
+                <span className="text-red-400">{failedCount} Failed</span>
+              </div>
+            )}
+          </div>
+
+          {/* Expand/Collapse Icon */}
+          <svg
+            className={`w-5 h-5 text-[var(--foreground-secondary)] transition-transform ${isExpanded ? 'rotate-180' : ''}`}
+            fill="none"
+            stroke="currentColor"
+            viewBox="0 0 24 24"
+          >
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
+          </svg>
+        </div>
+      </div>
+
+      {/* Error Analysis Modal */}
+      {errorModal && (
+        <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]" onClick={() => !errorModal.isFixing && setErrorModal(null)}>
+          <div
+            className="bg-[var(--surface)] border border-[var(--border)] rounded-xl max-w-3xl w-full mx-4 max-h-[85vh] overflow-hidden"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
+              <div className="flex items-center gap-3">
+                <h3 className="font-bold text-lg flex items-center gap-2">
+                  <span>🔍</span>
+                  Error Analysis: {errorModal.project}
+                </h3>
+                {errorModal.isFixing && (
+                  <span className="px-2 py-1 text-xs rounded-full bg-yellow-500/20 text-yellow-400 flex items-center gap-1">
+                    <svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
+                      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+                      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+                    </svg>
+                    Fixing (Attempt {errorModal.fixAttempt})...
+                  </span>
+                )}
+                {errorModal.fixAttempt && errorModal.fixAttempt > 0 && !errorModal.isFixing && (
+                  <span className="px-2 py-1 text-xs rounded-full bg-blue-500/20 text-blue-400">
+                    Attempt {errorModal.fixAttempt}/5
+                  </span>
+                )}
+              </div>
+              <button
+                onClick={() => !errorModal.isFixing && setErrorModal(null)}
+                className="text-[var(--foreground-secondary)] hover:text-[var(--foreground)] text-xl disabled:opacity-50"
+                disabled={errorModal.isFixing}
+              >
+                ×
+              </button>
+            </div>
+            <div className="p-4 overflow-y-auto max-h-[60vh]">
+              <pre className="whitespace-pre-wrap text-sm font-mono bg-[var(--background)] p-4 rounded-lg">
+                {errorModal.analysis}
+              </pre>
+            </div>
+            <div className="p-4 border-t border-[var(--border)] flex items-center justify-between">
+              <div className="text-xs text-[var(--foreground-tertiary)]">
+                Auto-fix uses AI to diagnose and repair issues (max 5 attempts)
+              </div>
+              <div className="flex items-center gap-2">
+                <button
+                  onClick={() => {
+                    navigator.clipboard.writeText(errorModal.analysis)
+                    alert('Copied to clipboard!')
+                  }}
+                  className="px-4 py-2 text-sm rounded bg-[var(--surface-alt)] hover:bg-[var(--border)]"
+                  disabled={errorModal.isFixing}
+                >
+                  Copy
+                </button>
+                <button
+                  onClick={() => handleFixAndRestart(
+                    errorModal.projectPath,
+                    errorModal.project,
+                    errorModal.analysis,
+                    (errorModal.fixAttempt || 0) + 1
+                  )}
+                  disabled={errorModal.isFixing || (errorModal.fixAttempt || 0) >= 5}
+                  className="px-4 py-2 text-sm rounded bg-green-500/20 text-green-400 hover:bg-green-500/30 border border-green-500/40 disabled:opacity-50 flex items-center gap-2"
+                >
+                  {errorModal.isFixing ? (
+                    <>
+                      <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                        <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+                        <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+                      </svg>
+                      Fixing...
+                    </>
+                  ) : (
+                    <>🔧 Fix & Restart</>
+                  )}
+                </button>
+                <button
+                  onClick={() => setErrorModal(null)}
+                  className="px-4 py-2 text-sm rounded bg-[var(--surface-alt)] hover:bg-[var(--border)]"
+                  disabled={errorModal.isFixing}
+                >
+                  Close
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Process Details Modal */}
+      {detailModal && (
+        <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]" onClick={() => setDetailModal(null)}>
+          <div
+            className="bg-[var(--surface)] border border-[var(--border)] rounded-xl max-w-4xl w-full mx-4 max-h-[85vh] overflow-hidden"
+            onClick={(e) => e.stopPropagation()}
+          >
+            {/* Header */}
+            <div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
+              <div className="flex items-center gap-3">
+                <h3 className="font-bold text-lg">📋 {detailModal.project}</h3>
+                <span className={`px-2 py-0.5 rounded text-xs font-medium ${
+                  detailModal.status === 'running' ? 'bg-green-500/20 text-green-400' :
+                  detailModal.status === 'paused' ? 'bg-yellow-500/20 text-yellow-400' :
+                  detailModal.status === 'completed' ? 'bg-blue-500/20 text-blue-400' :
+                  detailModal.status === 'failed' ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'
+                }`}>
+                  {detailModal.status.toUpperCase()}
+                </span>
+              </div>
+              <button
+                onClick={() => setDetailModal(null)}
+                className="text-[var(--foreground-secondary)] hover:text-[var(--foreground)] text-2xl leading-none"
+              >
+                ×
+              </button>
+            </div>
+
+            {/* Process Info Bar */}
+            <div className="px-4 py-2 bg-[var(--background)] border-b border-[var(--border)] flex items-center gap-6 text-sm">
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">PID:</span>{' '}
+                <span className="font-mono">{detailModal.pid || 'N/A'}</span>
+              </div>
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">CPU:</span>{' '}
+                <span className="font-mono">{detailModal.cpu}%</span>
+              </div>
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">Memory:</span>{' '}
+                <span className="font-mono">{detailModal.memory}</span>
+              </div>
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">Progress:</span>{' '}
+                <span className="font-mono text-[var(--primary)]">{detailModal.progress}</span>
+              </div>
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">Started:</span>{' '}
+                <span>{detailModal.startTime}</span>
+              </div>
+              <div>
+                <span className="text-[var(--foreground-tertiary)]">Port:</span>{' '}
+                <span className="font-mono text-[var(--primary)]">
+                  {detailModal.port || PROJECT_PORTS[detailModal.project.toLowerCase()] || 'N/A'}
+                </span>
+              </div>
+              <button
+                onClick={() => {
+                  const port = detailModal.port || PROJECT_PORTS[detailModal.project.toLowerCase()] || 3000
+                  window.open(`http://45.61.58.125:${port}/`, '_blank')
+                }}
+                className="ml-auto px-3 py-1 text-xs rounded bg-[var(--primary)]/20 text-[var(--primary)] hover:bg-[var(--primary)]/30 flex items-center gap-1"
+              >
+                Open App ↗
+              </button>
+            </div>
+
+            {/* Content - Two Columns */}
+            <div className="flex h-[60vh]">
+              {/* Stories Panel */}
+              <div className="w-1/2 border-r border-[var(--border)] flex flex-col">
+                <div className="p-3 border-b border-[var(--border)] font-medium text-sm">
+                  📝 User Stories ({detailModal.stories.length})
+                </div>
+                <div className="flex-1 overflow-y-auto p-2">
+                  {detailModal.stories.length === 0 ? (
+                    <p className="text-[var(--foreground-tertiary)] text-sm p-2">No stories found</p>
+                  ) : (
+                    <div className="space-y-2">
+                      {detailModal.stories.map((story, idx) => (
+                        <div
+                          key={idx}
+                          className={`p-2 rounded border ${
+                            story.status === 'DONE' || story.status === 'pass' ? 'border-green-500/30 bg-green-500/10' :
+                            story.status === 'RUNNING' || story.status === 'running' ? 'border-yellow-500/30 bg-yellow-500/10' :
+                            story.status === 'FAIL' || story.status === 'fail' ? 'border-red-500/30 bg-red-500/10' :
+                            'border-[var(--border)] bg-[var(--surface-alt)]'
+                          }`}
+                        >
+                          <div className="flex items-center justify-between">
+                            <span className="font-mono text-sm text-[var(--primary)]">{story.id}</span>
+                            <span className={`text-xs px-1.5 py-0.5 rounded ${
+                              story.status === 'DONE' || story.status === 'pass' ? 'bg-green-500/20 text-green-400' :
+                              story.status === 'RUNNING' || story.status === 'running' ? 'bg-yellow-500/20 text-yellow-400' :
+                              story.status === 'FAIL' || story.status === 'fail' ? 'bg-red-500/20 text-red-400' :
+                              'bg-gray-500/20 text-gray-400'
+                            }`}>
+                              {story.status}
+                            </span>
+                          </div>
+                          <div className="text-xs text-[var(--foreground-secondary)] mt-1 truncate">{story.title}</div>
+                          {story.notes && (
+                            <div className="text-xs text-[var(--foreground-tertiary)] mt-1 italic">{story.notes}</div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  )}
+                </div>
+              </div>
+
+              {/* Logs Panel */}
+              <div className="w-1/2 flex flex-col">
+                <div className="p-3 border-b border-[var(--border)] font-medium text-sm flex items-center justify-between">
+                  <span>📺 Live Logs</span>
+                  <button
+                    onClick={() => {
+                      navigator.clipboard.writeText(detailModal.logs.join('\n'))
+                      alert('Logs copied!')
+                    }}
+                    className="text-xs text-[var(--foreground-tertiary)] hover:text-[var(--foreground)]"
+                  >
+                    Copy
+                  </button>
+                </div>
+                <div className="flex-1 overflow-y-auto p-2 bg-[var(--background)] font-mono text-xs">
+                  {detailModal.logs.length === 0 ? (
+                    <p className="text-[var(--foreground-tertiary)] p-2">No logs available</p>
+                  ) : (
+                    detailModal.logs.map((log, idx) => (
+                      <div
+                        key={idx}
+                        className={`py-0.5 ${
+                          log.includes('Error') || log.includes('FAIL') ? 'text-red-400' :
+                          log.includes('✓') || log.includes('DONE') || log.includes('pass') ? 'text-green-400' :
+                          log.includes('Running') || log.includes('RUNNING') ? 'text-yellow-400' :
+                          'text-[var(--foreground-secondary)]'
+                        }`}
+                      >
+                        {log}
+                      </div>
+                    ))
+                  )}
+                </div>
+              </div>
+            </div>
+
+            {/* Footer */}
+            <div className="p-3 border-t border-[var(--border)] flex items-center justify-between">
+              <span className="text-xs text-[var(--foreground-tertiary)]">
+                Path: {detailModal.projectPath}
+              </span>
+              <button
+                onClick={() => setDetailModal(null)}
+                className="px-4 py-2 text-sm rounded bg-[var(--surface-alt)] hover:bg-[var(--border)]"
+              >
+                Close
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Loading Overlay */}
+      {loadingDetails && (
+        <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-[100]">
+          <div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg px-6 py-4 flex items-center gap-3">
+            <svg className="w-5 h-5 animate-spin text-[var(--primary)]" fill="none" viewBox="0 0 24 24">
+              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+            </svg>
+            <span>Loading process details...</span>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx
new file mode 100644
index 0000000..cec8bed
--- /dev/null
+++ b/components/ProjectSelector.tsx
@@ -0,0 +1,123 @@
+'use client'
+
+import { useState, useRef, useEffect } from 'react'
+
+interface Project {
+  name: string
+  path: string
+  hasClaudeMd: boolean
+}
+
+interface Props {
+  projects: Project[]
+  selectedProject: Project | null
+  onSelect: (project: Project) => void
+  onCreateNew: () => void
+}
+
+export default function ProjectSelector({ projects, selectedProject, onSelect, onCreateNew }: Props) {
+  const [isOpen, setIsOpen] = useState(false)
+  const [search, setSearch] = useState('')
+  const dropdownRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    function handleClickOutside(event: MouseEvent) {
+      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+        setIsOpen(false)
+      }
+    }
+    document.addEventListener('mousedown', handleClickOutside)
+    return () => document.removeEventListener('mousedown', handleClickOutside)
+  }, [])
+
+  const filteredProjects = projects.filter(p =>
+    p.name.toLowerCase().includes(search.toLowerCase())
+  )
+
+  return (
+    <div className="relative" ref={dropdownRef}>
+      <button
+        onClick={() => setIsOpen(!isOpen)}
+        className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--surface-alt)] border border-[var(--border)] hover:border-[var(--primary)] transition-colors min-w-[200px]"
+      >
+        <span className="text-[var(--foreground-secondary)]">Project:</span>
+        <span className="font-medium truncate max-w-[150px]">
+          {selectedProject?.name || 'Select...'}
+        </span>
+        <svg
+          className={`w-4 h-4 text-[var(--foreground-secondary)] transition-transform ${isOpen ? 'rotate-180' : ''}`}
+          fill="none"
+          stroke="currentColor"
+          viewBox="0 0 24 24"
+        >
+          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+        </svg>
+      </button>
+
+      {isOpen && (
+        <div className="absolute top-full mt-2 right-0 w-80 bg-[var(--surface)] border border-[var(--border)] rounded-xl shadow-lg overflow-hidden z-50">
+          {/* Search */}
+          <div className="p-3 border-b border-[var(--border)]">
+            <input
+              type="text"
+              placeholder="Search projects..."
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              className="input text-sm"
+              autoFocus
+            />
+          </div>
+
+          {/* Project List */}
+          <div className="max-h-64 overflow-y-auto">
+            {filteredProjects.map((project) => (
+              <button
+                key={project.path}
+                onClick={() => {
+                  onSelect(project)
+                  setIsOpen(false)
+                  setSearch('')
+                }}
+                className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-[var(--surface-alt)] transition-colors ${
+                  selectedProject?.path === project.path ? 'bg-[var(--surface-alt)]' : ''
+                }`}
+              >
+                <span className="text-lg">📁</span>
+                <div className="flex-1 text-left">
+                  <div className="font-medium">{project.name}</div>
+                  <div className="text-xs text-[var(--foreground-tertiary)] truncate">
+                    {project.path}
+                  </div>
+                </div>
+                {project.hasClaudeMd && (
+                  <span className="badge badge-success text-xs">CLAUDE.md</span>
+                )}
+              </button>
+            ))}
+            {filteredProjects.length === 0 && (
+              <div className="px-4 py-6 text-center text-[var(--foreground-tertiary)]">
+                No projects found
+              </div>
+            )}
+          </div>
+
+          {/* Create New */}
+          <div className="border-t border-[var(--border)] p-3">
+            <button
+              onClick={() => {
+                onCreateNew()
+                setIsOpen(false)
+              }}
+              className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white font-medium hover:bg-[var(--primary-dark)] transition-colors"
+            >
+              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+              </svg>
+              Create New Project
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/components/QuickModeStep.tsx b/components/QuickModeStep.tsx
new file mode 100644
index 0000000..9727e71
--- /dev/null
+++ b/components/QuickModeStep.tsx
@@ -0,0 +1,441 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Zap, ArrowRight, Loader2, ChevronDown, ChevronRight, Clock, Trash2 } from 'lucide-react'
+import { Project } from '@/app/page'
+
+interface QuickModeEntry {
+  id: number
+  project_name: string
+  project_path: string
+  entry_text: string
+  created_at: string
+}
+
+interface ClarifyingQuestion {
+  id: string
+  question: string
+  options: { key: string; label: string }[]
+  answer?: string
+}
+
+interface QuickModeStepProps {
+  selectedProject: Project | null
+  quickModeText: string
+  onTextChange: (text: string) => void
+  onGenerate: (prd: string) => void
+}
+
+export default function QuickModeStep({
+  selectedProject,
+  quickModeText,
+  onTextChange,
+  onGenerate
+}: QuickModeStepProps) {
+  const [isGenerating, setIsGenerating] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [recentEntries, setRecentEntries] = useState<QuickModeEntry[]>([])
+  const [isHistoryExpanded, setIsHistoryExpanded] = useState(false)
+
+  // Clarifying questions state
+  const [step, setStep] = useState<'input' | 'clarifying' | 'generating'>('input')
+  const [clarifyingQuestions, setClarifyingQuestions] = useState<ClarifyingQuestion[]>([])
+  const [isLoadingQuestions, setIsLoadingQuestions] = useState(false)
+
+  // Fetch recent entries on mount
+  useEffect(() => {
+    fetchRecentEntries()
+  }, [])
+
+  async function fetchRecentEntries() {
+    try {
+      const res = await fetch('/api/quickmode')
+      const data = await res.json()
+      if (data.entries) {
+        setRecentEntries(data.entries)
+      }
+    } catch (err) {
+      console.error('Failed to fetch recent entries:', err)
+    }
+  }
+
+  async function saveEntry() {
+    if (!selectedProject || !quickModeText.trim()) return
+
+    try {
+      await fetch('/api/quickmode', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          projectName: selectedProject.name,
+          projectPath: selectedProject.path,
+          entryText: quickModeText
+        })
+      })
+      // Refresh the list
+      fetchRecentEntries()
+    } catch (err) {
+      console.error('Failed to save entry:', err)
+    }
+  }
+
+  // Get clarifying questions first
+  async function handleSubmit() {
+    if (!selectedProject || !quickModeText.trim()) return
+
+    setIsLoadingQuestions(true)
+    setError(null)
+
+    // Save to database first
+    await saveEntry()
+
+    try {
+      // Extract a title from the first line or first 50 chars
+      const firstLine = quickModeText.split('\n')[0].trim()
+      const featureTitle = firstLine.length > 50
+        ? firstLine.substring(0, 50) + '...'
+        : firstLine || 'Quick Mode Task'
+
+      // Call clarify endpoint to get questions
+      const res = await fetch('/api/prd/clarify', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          projectName: selectedProject.name,
+          featureTitle,
+          problemStatement: quickModeText,
+          targetUser: 'D', // All users
+          scope: 'B', // Full-featured
+          priority: 'B', // High priority
+          keyFeatures: quickModeText,
+          nonGoals: '',
+          successMetrics: '',
+          components: [],
+          integrations: '',
+          techStack: 'Existing project stack',
+          additionalAnswers: '',
+          projectPath: selectedProject.path
+        })
+      })
+
+      const data = await res.json()
+
+      if (data.questions && data.questions.length > 0) {
+        setClarifyingQuestions(data.questions)
+        setStep('clarifying')
+      } else {
+        // No questions, go straight to generate
+        await generatePRD([])
+      }
+    } catch (err) {
+      setError('Failed to get clarifying questions. Please try again.')
+      console.error('Quick mode clarify error:', err)
+    } finally {
+      setIsLoadingQuestions(false)
+    }
+  }
+
+  // Handle answering a clarifying question
+  function handleClarifyingAnswer(questionId: string, answer: string) {
+    setClarifyingQuestions(prev =>
+      prev.map(q => q.id === questionId ? { ...q, answer } : q)
+    )
+  }
+
+  // Generate PRD with answers
+  async function generatePRD(answers: ClarifyingQuestion[]) {
+    if (!selectedProject) return
+
+    setStep('generating')
+    setIsGenerating(true)
+    setError(null)
+
+    try {
+      const firstLine = quickModeText.split('\n')[0].trim()
+      const featureTitle = firstLine.length > 50
+        ? firstLine.substring(0, 50) + '...'
+        : firstLine || 'Quick Mode Task'
+
+      // Format answers
+      const formattedAnswers = answers
+        .filter(q => q.answer)
+        .map((q, i) => `${i + 1}${q.answer}: ${q.question} → ${q.options.find(o => o.key === q.answer)?.label || q.answer}`)
+        .join('\n')
+
+      const res = await fetch('/api/prd/generate', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          projectName: selectedProject.name,
+          featureTitle,
+          problemStatement: quickModeText,
+          targetUser: 'D', // All users
+          scope: 'B', // Full-featured
+          priority: 'B', // High priority
+          keyFeatures: quickModeText,
+          nonGoals: '',
+          successMetrics: '',
+          components: [],
+          integrations: '',
+          techStack: 'Existing project stack',
+          additionalAnswers: formattedAnswers ? `Clarifying Questions:\n${formattedAnswers}` : '',
+          projectPath: selectedProject.path
+        })
+      })
+
+      const data = await res.json()
+
+      if (data.error) {
+        setError(data.error)
+        setStep('input')
+      } else if (data.prd) {
+        onGenerate(data.prd)
+      }
+    } catch (err) {
+      setError('Failed to generate PRD. Please try again.')
+      console.error('Quick mode generation error:', err)
+      setStep('input')
+    } finally {
+      setIsGenerating(false)
+    }
+  }
+
+  // Submit clarifying answers
+  function submitClarifyingAnswers() {
+    const unanswered = clarifyingQuestions.filter(q => !q.answer)
+    if (unanswered.length > 0) {
+      setError(`Please answer all questions. ${unanswered.length} remaining.`)
+      return
+    }
+    generatePRD(clarifyingQuestions)
+  }
+
+  function loadEntry(entry: QuickModeEntry) {
+    onTextChange(entry.entry_text)
+  }
+
+  function formatDate(dateStr: string) {
+    const date = new Date(dateStr)
+    return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+  }
+
+  function truncateText(text: string, maxLength: number = 80) {
+    if (text.length <= maxLength) return text
+    return text.substring(0, maxLength) + '...'
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center gap-3">
+        <div className="p-2 rounded-lg bg-[var(--primary)]/20">
+          <Zap className="w-5 h-5 text-[var(--primary)]" />
+        </div>
+        <div>
+          <h2 className="text-xl font-semibold text-[var(--foreground)]">Quick Mode</h2>
+          <p className="text-sm text-[var(--foreground-secondary)]">
+            {step === 'input' && 'Describe your task, answer clarifying questions, then generate PRD'}
+            {step === 'clarifying' && 'Answer these questions to help generate a better PRD'}
+            {step === 'generating' && 'Generating your PRD...'}
+          </p>
+        </div>
+      </div>
+
+      {/* Project Status */}
+      {selectedProject ? (
+        <div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--surface-alt)]">
+          <p className="text-sm text-[var(--foreground-secondary)]">
+            Working on: <span className="font-medium text-[var(--primary)]">{selectedProject.name}</span>
+          </p>
+        </div>
+      ) : (
+        <div className="p-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10">
+          <p className="text-sm text-yellow-400">
+            Select a project from the dropdown above to get started
+          </p>
+        </div>
+      )}
+
+      {/* Error Message */}
+      {error && (
+        <div className="p-3 rounded-lg border border-red-500/30 bg-red-500/10">
+          <p className="text-sm text-red-400">{error}</p>
+        </div>
+      )}
+
+      {/* Step: Input */}
+      {step === 'input' && (
+        <>
+          {/* Text Area */}
+          <div className="space-y-2">
+            <label className="text-sm font-medium text-[var(--foreground)]">
+              What do you want to do?
+            </label>
+            <textarea
+              value={quickModeText}
+              onChange={(e) => onTextChange(e.target.value)}
+              disabled={isLoadingQuestions}
+              placeholder="Describe your task, feature, bug fix, or any work you need done...
+
+Examples:
+- Add a dark mode toggle to the settings page
+- Fix the login form validation error
+- Create an API endpoint for user profiles
+- Refactor the database queries for better performance"
+              className="w-full h-64 p-4 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--foreground-tertiary)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent resize-none disabled:opacity-50"
+            />
+            <p className="text-xs text-[var(--foreground-tertiary)]">
+              {quickModeText.length} characters
+            </p>
+          </div>
+
+          {/* Submit Button */}
+          <div className="flex justify-end">
+            <button
+              onClick={handleSubmit}
+              disabled={!selectedProject || !quickModeText.trim() || isLoadingQuestions}
+              className="flex items-center gap-2 px-6 py-3 rounded-lg bg-[var(--primary)] text-white font-medium hover:bg-[var(--primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+            >
+              {isLoadingQuestions ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Getting Questions...
+                </>
+              ) : (
+                <>
+                  Next: Clarifying Questions
+                  <ArrowRight className="w-4 h-4" />
+                </>
+              )}
+            </button>
+          </div>
+        </>
+      )}
+
+      {/* Step: Clarifying Questions */}
+      {step === 'clarifying' && (
+        <div className="card p-6 border border-[var(--border)] rounded-xl bg-[var(--surface)]">
+          <div className="mb-6">
+            <h3 className="text-xl font-semibold flex items-center gap-2 mb-2">
+              <span>🤔</span>
+              Clarifying Questions
+            </h3>
+            <p className="text-[var(--foreground-secondary)] text-sm">
+              Based on your input, please answer these questions. Select the best option (A, B, C, or D).
+            </p>
+          </div>
+
+          <div className="space-y-8">
+            {clarifyingQuestions.map((q, index) => (
+              <div key={q.id} className="border-b border-[var(--border)] pb-6 last:border-b-0 last:pb-0">
+                <div className="font-bold text-lg mb-4">
+                  Q{index + 1}: {q.question}
+                </div>
+                <div className="space-y-2 pl-4">
+                  {q.options.map((opt) => (
+                    <button
+                      key={opt.key}
+                      onClick={() => handleClarifyingAnswer(q.id, opt.key)}
+                      className={`w-full p-3 rounded-lg border text-left transition-all flex items-start gap-3 ${
+                        q.answer === opt.key
+                          ? 'bg-[var(--primary)]/20 border-[var(--primary)]'
+                          : 'bg-[var(--background)] border-[var(--border)] hover:border-[var(--primary)]/50'
+                      }`}
+                    >
+                      <span className={`font-bold min-w-[24px] ${q.answer === opt.key ? 'text-[var(--primary)]' : ''}`}>
+                        {opt.key})
+                      </span>
+                      <span className="text-[var(--foreground-secondary)]">{opt.label}</span>
+                    </button>
+                  ))}
+                </div>
+              </div>
+            ))}
+          </div>
+
+          <div className="flex items-center justify-between mt-6 pt-4 border-t border-[var(--border)]">
+            <button
+              onClick={() => setStep('input')}
+              className="px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--foreground-secondary)] hover:bg-[var(--surface-alt)] transition-colors"
+            >
+              ← Back
+            </button>
+            <div className="flex items-center gap-2 text-sm text-[var(--foreground-secondary)]">
+              <span>
+                {clarifyingQuestions.filter(q => q.answer).length}/{clarifyingQuestions.length} answered
+              </span>
+            </div>
+            <button
+              onClick={submitClarifyingAnswers}
+              disabled={clarifyingQuestions.some(q => !q.answer)}
+              className="flex items-center gap-2 px-6 py-3 rounded-lg bg-[var(--primary)] text-white font-medium hover:bg-[var(--primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+            >
+              Generate PRD
+              <ArrowRight className="w-4 h-4" />
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* Step: Generating */}
+      {step === 'generating' && (
+        <div className="card p-8 text-center border border-[var(--border)] rounded-xl bg-[var(--surface)]">
+          <div className="flex flex-col items-center gap-4">
+            <Loader2 className="w-12 h-12 animate-spin text-[var(--primary)]" />
+            <div className="text-xl font-medium">Generating PRD...</div>
+            <p className="text-[var(--foreground-secondary)]">
+              Claude is creating your Product Requirements Document based on your inputs.
+            </p>
+          </div>
+        </div>
+      )}
+
+      {/* Recent Entries - Collapsible (only show in input step) */}
+      {step === 'input' && recentEntries.length > 0 && (
+        <div className="border-t border-[var(--border)] pt-4">
+          <button
+            onClick={() => setIsHistoryExpanded(!isHistoryExpanded)}
+            className="flex items-center gap-2 text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] transition-colors w-full"
+          >
+            {isHistoryExpanded ? (
+              <ChevronDown className="w-4 h-4" />
+            ) : (
+              <ChevronRight className="w-4 h-4" />
+            )}
+            <Clock className="w-4 h-4" />
+            <span>Recent Entries ({recentEntries.length})</span>
+          </button>
+
+          {isHistoryExpanded && (
+            <div className="mt-3 space-y-2">
+              {recentEntries.map((entry) => (
+                <div
+                  key={entry.id}
+                  onClick={() => loadEntry(entry)}
+                  className="p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] hover:border-[var(--primary)] cursor-pointer transition-colors group"
+                >
+                  <div className="flex items-start justify-between gap-2">
+                    <div className="flex-1 min-w-0">
+                      <p className="text-sm text-[var(--foreground)] truncate">
+                        {truncateText(entry.entry_text)}
+                      </p>
+                      <div className="flex items-center gap-2 mt-1">
+                        <span className="text-xs text-[var(--primary)]">{entry.project_name}</span>
+                        <span className="text-xs text-[var(--foreground-tertiary)]">
+                          {formatDate(entry.created_at)}
+                        </span>
+                      </div>
+                    </div>
+                    <span className="text-xs text-[var(--foreground-tertiary)] group-hover:text-[var(--primary)]">
+                      Click to load
+                    </span>
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/components/RalphConverterStep.tsx b/components/RalphConverterStep.tsx
new file mode 100644
index 0000000..2be16bf
--- /dev/null
+++ b/components/RalphConverterStep.tsx
@@ -0,0 +1,472 @@
+'use client'
+
+import { useState } from 'react'
+
+interface UserStory {
+  id: string
+  title: string
+  description: string
+  acceptanceCriteria: string[]
+  priority: number
+  passes: boolean
+  notes: string
+}
+
+interface RalphData {
+  project: string
+  branchName: string
+  description: string
+  userStories: UserStory[]
+}
+
+interface Props {
+  prdMarkdown: string
+  projectName: string
+  onConvert: (data: RalphData) => void
+  ralphData: RalphData | null
+  validationResults: Record<string, boolean>
+  setValidationResults: (results: Record<string, boolean>) => void
+  onBack: () => void
+}
+
+const validationChecks = [
+  { id: 'storySize', label: 'Each story is small enough (2-3 sentence description)' },
+  { id: 'ordering', label: 'Stories ordered by dependency (schema → backend → UI)' },
+  { id: 'typecheck', label: 'Every story has "Typecheck passes" criterion' },
+  { id: 'browser', label: 'UI stories have "Verify in browser" criterion' },
+  { id: 'noDeps', label: 'No story depends on a later story' },
+  { id: 'verifiable', label: 'Acceptance criteria are verifiable (not vague)' },
+]
+
+export default function RalphConverterStep({
+  prdMarkdown,
+  projectName,
+  onConvert,
+  ralphData,
+  validationResults,
+  setValidationResults,
+  onBack
+}: Props) {
+  const [isConverting, setIsConverting] = useState(false)
+  const [editingStory, setEditingStory] = useState<string | null>(null)
+  const [localRalphData, setLocalRalphData] = useState<RalphData | null>(ralphData)
+  const [validationIssues, setValidationIssues] = useState<string[]>([])
+  const [archivedTo, setArchivedTo] = useState<string | null>(null)
+  const [isFixing, setIsFixing] = useState(false)
+  const [fixedMessage, setFixedMessage] = useState<string | null>(null)
+
+  // Determine story type based on title/description
+  function getStoryType(story: UserStory): 'schema' | 'backend' | 'ui' | 'other' {
+    const text = `${story.title} ${story.description}`.toLowerCase()
+    if (text.includes('schema') || text.includes('database') || text.includes('model') || text.includes('migration')) {
+      return 'schema'
+    }
+    if (text.includes('api') || text.includes('endpoint') || text.includes('backend') || text.includes('service') || text.includes('server')) {
+      return 'backend'
+    }
+    if (text.includes('ui') || text.includes('component') || text.includes('page') || text.includes('form') || text.includes('button') || text.includes('display') || text.includes('view') || text.includes('dashboard') || text.includes('interface')) {
+      return 'ui'
+    }
+    return 'other'
+  }
+
+  // Auto-fix all validation issues
+  function handleFixIssues() {
+    if (!localRalphData) return
+    setIsFixing(true)
+    setFixedMessage(null)
+
+    try {
+      let fixedStories = localRalphData.userStories.map(story => ({ ...story, acceptanceCriteria: [...story.acceptanceCriteria] }))
+      const fixedIssues: string[] = []
+
+      // Fix 1: Add "Verify in browser" to UI stories that don't have it
+      fixedStories = fixedStories.map(story => {
+        const storyType = getStoryType(story)
+        if (storyType === 'ui') {
+          const hasBrowserCriterion = story.acceptanceCriteria.some(c =>
+            c.toLowerCase().includes('verify in browser') ||
+            c.toLowerCase().includes('browser verification') ||
+            c.toLowerCase().includes('visually verify')
+          )
+          if (!hasBrowserCriterion) {
+            fixedIssues.push(`Added "Verify in browser" to ${story.id}`)
+            return {
+              ...story,
+              acceptanceCriteria: [...story.acceptanceCriteria, 'Verify in browser that the UI displays correctly']
+            }
+          }
+        }
+        return story
+      })
+
+      // Fix 2: Add "Typecheck passes" to stories that don't have it
+      fixedStories = fixedStories.map(story => {
+        const hasTypecheck = story.acceptanceCriteria.some(c =>
+          c.toLowerCase().includes('typecheck') ||
+          c.toLowerCase().includes('type check') ||
+          c.toLowerCase().includes('typescript')
+        )
+        if (!hasTypecheck) {
+          fixedIssues.push(`Added "Typecheck passes" to ${story.id}`)
+          return {
+            ...story,
+            acceptanceCriteria: [...story.acceptanceCriteria, 'Typecheck passes']
+          }
+        }
+        return story
+      })
+
+      // Fix 3: Reorder stories by type (schema → backend → other → ui)
+      const typeOrder = { schema: 0, backend: 1, other: 2, ui: 3 }
+      const originalOrder = fixedStories.map(s => s.id).join(',')
+
+      fixedStories.sort((a, b) => {
+        const typeA = getStoryType(a)
+        const typeB = getStoryType(b)
+        return typeOrder[typeA] - typeOrder[typeB]
+      })
+
+      const newOrder = fixedStories.map(s => s.id).join(',')
+      if (originalOrder !== newOrder) {
+        fixedIssues.push('Reordered stories: schema → backend → other → UI')
+      }
+
+      // Update priorities after reordering
+      fixedStories = fixedStories.map((story, idx) => ({
+        ...story,
+        priority: idx + 1
+      }))
+
+      // Create the new ralph data
+      const newRalphData = { ...localRalphData, userStories: fixedStories }
+
+      // Update state
+      setLocalRalphData(newRalphData)
+
+      // Update validation results - mark all fixable ones as true
+      const newValidationResults = {
+        ...validationResults,
+        browser: true,
+        typecheck: true,
+        ordering: true,
+        noDeps: true
+      }
+      setValidationResults(newValidationResults)
+
+      // Clear issues that were fixed - use more inclusive pattern matching
+      const remainingIssues = validationIssues.filter(issue => {
+        const lowerIssue = issue.toLowerCase()
+        // Remove issues we just fixed
+        if (lowerIssue.includes('verify in browser') || lowerIssue.includes('browser')) return false
+        if (lowerIssue.includes('story ordering') || lowerIssue.includes('should come before')) return false
+        if (lowerIssue.includes('typecheck')) return false
+        return true
+      })
+      setValidationIssues(remainingIssues)
+
+      // Show what was fixed
+      if (fixedIssues.length > 0) {
+        setFixedMessage(`Fixed ${fixedIssues.length} issue(s): ${fixedIssues.join(', ')}`)
+        console.log('Fixed issues:', fixedIssues)
+      } else {
+        setFixedMessage('No auto-fixable issues found')
+      }
+
+    } catch (err) {
+      console.error('Error fixing issues:', err)
+      setFixedMessage('Error fixing issues: ' + (err as Error).message)
+    } finally {
+      setIsFixing(false)
+    }
+  }
+
+  async function handleConvert() {
+    setIsConverting(true)
+    setValidationIssues([])
+    try {
+      const res = await fetch('/api/ralph/convert', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ prdMarkdown, projectName })
+      })
+      const data = await res.json()
+      if (data.ralphData) {
+        setLocalRalphData(data.ralphData)
+
+        // Use validation results from API
+        if (data.validation) {
+          setValidationIssues(data.validation.issues || [])
+
+          // Map validation issues to checkbox states
+          const results: Record<string, boolean> = {}
+          const issues = data.validation.issues || []
+
+          // Check each validation type based on issue patterns
+          results['storySize'] = !issues.some((i: string) => i.includes('Description too long'))
+          results['typecheck'] = !issues.some((i: string) => i.includes('Typecheck passes'))
+          results['browser'] = !issues.some((i: string) => i.includes('Verify in browser'))
+          results['verifiable'] = !issues.some((i: string) => i.includes('Vague criterion'))
+          results['ordering'] = !issues.some((i: string) => i.includes('Story ordering'))
+          results['noDeps'] = !issues.some((i: string) => i.includes('depends on a later'))
+
+          setValidationResults(results)
+        }
+
+        if (data.archivedTo) {
+          setArchivedTo(data.archivedTo)
+        }
+      }
+    } catch (err) {
+      console.error('Failed to convert:', err)
+    } finally {
+      setIsConverting(false)
+    }
+  }
+
+  function handleSaveAndNext() {
+    if (localRalphData) {
+      onConvert(localRalphData)
+    }
+  }
+
+  function moveStory(index: number, direction: 'up' | 'down') {
+    if (!localRalphData) return
+    const newStories = [...localRalphData.userStories]
+    const targetIndex = direction === 'up' ? index - 1 : index + 1
+    if (targetIndex < 0 || targetIndex >= newStories.length) return
+
+    [newStories[index], newStories[targetIndex]] = [newStories[targetIndex], newStories[index]]
+    // Update priorities
+    newStories.forEach((story, i) => { story.priority = i + 1 })
+    setLocalRalphData({ ...localRalphData, userStories: newStories })
+  }
+
+  function deleteStory(id: string) {
+    if (!localRalphData) return
+    const newStories = localRalphData.userStories.filter(s => s.id !== id)
+    newStories.forEach((story, i) => { story.priority = i + 1 })
+    setLocalRalphData({ ...localRalphData, userStories: newStories })
+  }
+
+  const allValid = Object.values(validationResults).every(v => v)
+
+  return (
+    <div className="space-y-6">
+      {/* Side by Side View */}
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+        {/* PRD Input */}
+        <div className="card">
+          <div className="p-4 border-b border-[var(--border)] flex items-center gap-2">
+            <span>📄</span>
+            <h3 className="font-medium">PRD (Input)</h3>
+          </div>
+          <div className="p-4 max-h-80 overflow-y-auto">
+            <pre className="text-sm whitespace-pre-wrap">{prdMarkdown || 'No PRD generated yet'}</pre>
+          </div>
+        </div>
+
+        {/* prd.json Output */}
+        <div className="card">
+          <div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <span>📦</span>
+              <h3 className="font-medium">prd.json (Output)</h3>
+            </div>
+            {!localRalphData && (
+              <button
+                onClick={handleConvert}
+                className="btn-primary text-sm"
+                disabled={isConverting || !prdMarkdown}
+              >
+                {isConverting ? 'Converting...' : 'Convert →'}
+              </button>
+            )}
+          </div>
+          <div className="p-4 max-h-80 overflow-y-auto">
+            {localRalphData ? (
+              <pre className="code-block text-xs">
+                {JSON.stringify(localRalphData, null, 2)}
+              </pre>
+            ) : (
+              <p className="text-[var(--foreground-tertiary)] text-sm">
+                Click "Convert" to generate prd.json
+              </p>
+            )}
+          </div>
+        </div>
+      </div>
+
+      {/* Archive Notice */}
+      {archivedTo && (
+        <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-blue-400 text-sm">
+          Previous prd.json archived to: {archivedTo}
+        </div>
+      )}
+
+      {/* Validation Issues */}
+      {validationIssues.length > 0 && (
+        <div className="card p-4 border-[var(--warning)]">
+          <h3 className="font-medium mb-3 flex items-center gap-2 text-[var(--warning)]">
+            <span>⚠️</span>
+            Validation Issues ({validationIssues.length})
+          </h3>
+          <ul className="space-y-2 text-sm">
+            {validationIssues.map((issue, idx) => (
+              <li key={idx} className="flex items-start gap-2 text-[var(--foreground-secondary)]">
+                <span className="text-[var(--warning)]">•</span>
+                {issue}
+              </li>
+            ))}
+          </ul>
+          <div className="mt-4 flex items-center gap-3">
+            <button
+              onClick={handleFixIssues}
+              className="btn-primary text-sm"
+              disabled={isFixing}
+            >
+              {isFixing ? 'Fixing...' : '🔧 Fix Validation Issues'}
+            </button>
+            <span className="text-xs text-[var(--foreground-tertiary)]">
+              or manually check the boxes below to proceed
+            </span>
+          </div>
+          {fixedMessage && (
+            <div className="mt-3 p-2 bg-green-500/20 border border-green-500/30 rounded text-green-400 text-sm">
+              ✅ {fixedMessage}
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Validation Checklist */}
+      {localRalphData && (
+        <div className="card p-4">
+          <h3 className="font-medium mb-4 flex items-center gap-2">
+            <span>✓</span>
+            Validation Checklist
+          </h3>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+            {validationChecks.map((check) => (
+              <div
+                key={check.id}
+                className={`checkbox-option ${validationResults[check.id] ? 'checked' : ''}`}
+                onClick={() => setValidationResults({
+                  ...validationResults,
+                  [check.id]: !validationResults[check.id]
+                })}
+              >
+                <div className={`checkbox-box ${validationResults[check.id] ? 'checked' : ''}`}>
+                  {validationResults[check.id] && (
+                    <svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
+                    </svg>
+                  )}
+                </div>
+                <span className="text-sm">{check.label}</span>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {/* Story Editor */}
+      {localRalphData && (
+        <div className="card p-4">
+          <h3 className="font-medium mb-4 flex items-center gap-2">
+            <span>📝</span>
+            Edit Stories (Drag to Reorder)
+          </h3>
+          <div className="space-y-2">
+            {localRalphData.userStories.map((story, index) => (
+              <div
+                key={story.id}
+                className="flex items-center gap-3 p-3 bg-[var(--surface-alt)] rounded-lg border border-[var(--border)]"
+              >
+                {/* Drag Handle */}
+                <span className="text-[var(--foreground-tertiary)] cursor-move">≡</span>
+
+                {/* Story Info */}
+                <div className="flex-1">
+                  <div className="font-medium text-sm">{story.id}: {story.title}</div>
+                  <div className="text-xs text-[var(--foreground-tertiary)]">
+                    Priority: {story.priority} | Criteria: {story.acceptanceCriteria.length}
+                  </div>
+                </div>
+
+                {/* Actions */}
+                <div className="flex items-center gap-2">
+                  <button
+                    onClick={() => moveStory(index, 'up')}
+                    disabled={index === 0}
+                    className="p-1 hover:bg-[var(--surface-elevated)] rounded disabled:opacity-30"
+                  >
+                    ↑
+                  </button>
+                  <button
+                    onClick={() => moveStory(index, 'down')}
+                    disabled={index === localRalphData.userStories.length - 1}
+                    className="p-1 hover:bg-[var(--surface-elevated)] rounded disabled:opacity-30"
+                  >
+                    ↓
+                  </button>
+                  <button
+                    onClick={() => setEditingStory(story.id)}
+                    className="p-1 hover:bg-[var(--surface-elevated)] rounded text-[var(--primary)]"
+                  >
+                    ✏️
+                  </button>
+                  <button
+                    onClick={() => deleteStory(story.id)}
+                    className="p-1 hover:bg-[var(--surface-elevated)] rounded text-[var(--error)]"
+                  >
+                    🗑️
+                  </button>
+                </div>
+              </div>
+            ))}
+          </div>
+          <div className="mt-4 flex items-center gap-3">
+            <button className="btn-secondary text-sm">+ Add Story</button>
+            <button className="btn-secondary text-sm">🔀 Split Selected</button>
+          </div>
+        </div>
+      )}
+
+      {/* Actions */}
+      <div className="flex items-center justify-between p-4 card">
+        <button onClick={onBack} className="btn-secondary">
+          ← Back to PRD
+        </button>
+        <div className="flex items-center gap-3">
+          <button
+            onClick={() => {
+              if (localRalphData) {
+                navigator.clipboard.writeText(JSON.stringify(localRalphData, null, 2))
+                alert('prd.json copied to clipboard!')
+              }
+            }}
+            className="btn-secondary"
+            disabled={!localRalphData}
+          >
+            Copy prd.json
+          </button>
+          <button
+            onClick={() => {
+              if (!localRalphData) return
+              if (!allValid) {
+                const proceed = confirm('Some validation checks are not passing. Proceed anyway?')
+                if (!proceed) return
+              }
+              handleSaveAndNext()
+            }}
+            className="btn-primary"
+            disabled={!localRalphData}
+          >
+            Next: Ralphy Boy →
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/components/RalphyBoyStep.tsx b/components/RalphyBoyStep.tsx
new file mode 100644
index 0000000..af8a2c2
--- /dev/null
+++ b/components/RalphyBoyStep.tsx
@@ -0,0 +1,476 @@
+'use client'
+
+import { useState, useRef, useEffect } from 'react'
+
+interface UserStory {
+  id: string
+  title: string
+  description: string
+  acceptanceCriteria: string[]
+  priority: number
+  passes: boolean
+  notes: string
+}
+
+interface RalphData {
+  project: string
+  branchName: string
+  description: string
+  userStories: UserStory[]
+}
+
+interface Props {
+  ralphData: RalphData | null
+  projectPath: string
+  isRunning: boolean
+  setIsRunning: (running: boolean) => void
+  currentStoryIndex: number
+  setCurrentStoryIndex: (index: number) => void
+  logs: string[]
+  setLogs: React.Dispatch<React.SetStateAction<string[]>>
+  onBack: () => void
+}
+
+export default function RalphyBoyStep({
+  ralphData,
+  projectPath,
+  isRunning,
+  setIsRunning,
+  currentStoryIndex,
+  setCurrentStoryIndex,
+  logs,
+  setLogs,
+  onBack
+}: Props) {
+  const [status, setStatus] = useState<'ready' | 'running' | 'paused' | 'completed' | 'error'>('ready')
+  const [storyResults, setStoryResults] = useState<Record<string, 'pending' | 'running' | 'pass' | 'fail'>>({})
+  const [storyTimes, setStoryTimes] = useState<Record<string, number>>({})
+  const [storyNotes, setStoryNotes] = useState<Record<string, string>>({})
+  const logsEndRef = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    if (logsEndRef.current) {
+      logsEndRef.current.scrollIntoView({ behavior: 'smooth' })
+    }
+  }, [logs])
+
+  // Proper polling with useEffect + setInterval (fixes stale closure bug)
+  useEffect(() => {
+    if (!isRunning) return
+
+    const pollStatus = async () => {
+      try {
+        const res = await fetch('/api/ralph/status')
+        const data = await res.json()
+
+        if (data.logs) {
+          setLogs(data.logs)
+        }
+        if (data.currentStory) {
+          setCurrentStoryIndex(data.currentStoryIndex)
+          setStoryResults(prev => ({ ...prev, [data.currentStory]: 'running' }))
+        }
+        if (data.completed) {
+          setStoryResults(data.storyResults || {})
+          setStoryTimes(data.storyTimes || {})
+          setStoryNotes(data.storyNotes || {})
+          setStatus('completed')
+          setIsRunning(false)
+        }
+      } catch (err) {
+        console.error('Poll error:', err)
+      }
+    }
+
+    // Initial poll immediately
+    pollStatus()
+
+    // Set up interval for subsequent polls
+    const interval = setInterval(pollStatus, 2000)
+
+    // Cleanup on unmount or when isRunning changes
+    return () => clearInterval(interval)
+  }, [isRunning, setLogs, setCurrentStoryIndex, setIsRunning])
+
+  async function startRalph() {
+    if (!ralphData) return
+
+    setIsRunning(true)
+    setStatus('running')
+    setLogs(['> Starting Ralphy Boy...', `> Branch: ${ralphData.branchName}`, ''])
+
+    // Initialize story results
+    const initial: Record<string, 'pending' | 'running' | 'pass' | 'fail'> = {}
+    ralphData.userStories.forEach(s => { initial[s.id] = 'pending' })
+    setStoryResults(initial)
+
+    try {
+      addLog(`> Sending request to /api/ralph/run...`)
+      addLog(`> Project: ${projectPath}`)
+      addLog(`> Stories: ${ralphData.userStories.length}`)
+
+      const res = await fetch('/api/ralph/run', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ ralphData, projectPath })
+      })
+
+      addLog(`> Response status: ${res.status}`)
+      const data = await res.json()
+
+      if (!res.ok || data.error) {
+        setStatus('error')
+        addLog(`> Error: ${data.error || 'Failed to start Ralph'}`)
+        setIsRunning(false)
+        return
+      }
+
+      addLog(`> Ralph started with PID ${data.pid}`)
+      addLog(`> Branch: ${data.branchName}`)
+      addLog('')
+      // Polling is now handled by useEffect when isRunning becomes true
+    } catch (err: any) {
+      setStatus('error')
+      addLog(`> Error starting Ralph: ${err?.message || err}`)
+      addLog(`> Error type: ${err?.name || 'Unknown'}`)
+      if (err?.cause) addLog(`> Cause: ${err.cause}`)
+      setIsRunning(false)
+    }
+  }
+
+  async function continueRalph() {
+    if (!projectPath) return
+
+    setIsRunning(true)
+    setStatus('running')
+    addLog('> Continuing Ralphy Boy from where we left off...')
+    addLog(`> Project: ${projectPath}`)
+    addLog('')
+
+    try {
+      const res = await fetch('/api/ralph/continue', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ projectPath })
+      })
+
+      const data = await res.json()
+
+      if (!res.ok || data.error) {
+        setStatus('error')
+        addLog(`> Error: ${data.error || 'Failed to continue Ralph'}`)
+        setIsRunning(false)
+        return
+      }
+
+      if (data.completed) {
+        setStatus('completed')
+        addLog('> All stories already completed!')
+        setIsRunning(false)
+        return
+      }
+
+      addLog(`> Continuing with ${data.pendingStories?.length || 0} pending stories`)
+      addLog(`> Stories: ${data.pendingStories?.join(', ') || 'none'}`)
+      addLog(`> PID: ${data.pid}`)
+      addLog('')
+      // Polling is handled by useEffect when isRunning becomes true
+    } catch (err: any) {
+      setStatus('error')
+      addLog(`> Error: ${err?.message || err}`)
+      setIsRunning(false)
+    }
+  }
+
+  function stopRalph() {
+    setIsRunning(false)
+    setStatus('ready')
+    addLog('> Stopped by user')
+    fetch('/api/ralph/stop', { method: 'POST' })
+  }
+
+  function addLog(message: string) {
+    setLogs(prev => [...prev, message])
+  }
+
+  const completedCount = Object.values(storyResults).filter(r => r === 'pass').length
+  const totalCount = ralphData?.userStories.length || 0
+  const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
+
+  return (
+    <div className="space-y-6">
+      {/* Control Panel */}
+      <div className="card p-6">
+        <h3 className="font-medium mb-4 flex items-center gap-2">
+          <span>🎮</span>
+          Control Panel
+        </h3>
+        <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
+          <div>
+            <div className="text-sm text-[var(--foreground-secondary)]">Branch:</div>
+            <div className="font-mono text-[var(--primary)]">{ralphData?.branchName || 'N/A'}</div>
+            <div className="text-sm mt-1">
+              Status: <span className={`font-medium ${
+                status === 'running' ? 'text-[var(--warning)]' :
+                status === 'completed' ? 'text-[var(--success)]' :
+                status === 'error' ? 'text-[var(--error)]' :
+                'text-[var(--foreground-secondary)]'
+              }`}>{status.toUpperCase()}</span>
+            </div>
+          </div>
+          <div className="flex items-center gap-3">
+            <button
+              onClick={startRalph}
+              className="btn-secondary"
+              disabled={isRunning || !ralphData}
+              title="Start fresh from the beginning"
+            >
+              🔄 RESTART
+            </button>
+            <button
+              onClick={continueRalph}
+              className="btn-primary"
+              disabled={isRunning || !projectPath}
+              title="Continue from where we left off"
+            >
+              ▶️ CONTINUE
+            </button>
+            <button
+              onClick={stopRalph}
+              className="btn-secondary"
+              disabled={!isRunning && status === 'ready'}
+            >
+              ⏹️ STOP
+            </button>
+          </div>
+        </div>
+      </div>
+
+      {/* Progress Board */}
+      <div className="card p-6">
+        <h3 className="font-medium mb-4 flex items-center gap-2">
+          <span>📊</span>
+          Progress Board
+        </h3>
+        <div className="overflow-x-auto">
+          <table className="w-full text-sm">
+            <thead>
+              <tr className="text-left text-[var(--foreground-secondary)] border-b border-[var(--border)]">
+                <th className="pb-3 pr-4">Story</th>
+                <th className="pb-3 pr-4">Status</th>
+                <th className="pb-3 pr-4">Time</th>
+                <th className="pb-3">Notes</th>
+              </tr>
+            </thead>
+            <tbody>
+              {ralphData?.userStories.map((story, index) => {
+                const result = storyResults[story.id] || 'pending'
+                const time = storyTimes[story.id]
+                const notes = storyNotes[story.id]
+
+                return (
+                  <tr key={story.id} className="border-b border-[var(--border)]">
+                    <td className="py-3 pr-4">
+                      <div className="font-medium">{story.id}</div>
+                      <div className="text-xs text-[var(--foreground-tertiary)] truncate max-w-[200px]">
+                        {story.title}
+                      </div>
+                    </td>
+                    <td className="py-3 pr-4">
+                      {result === 'pass' && <span className="badge badge-success">✅ PASS</span>}
+                      {result === 'fail' && <span className="badge badge-error">❌ FAIL</span>}
+                      {result === 'running' && (
+                        <span className="badge badge-warning">
+                          <svg className="w-3 h-3 animate-spin mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                          </svg>
+                          RUNNING
+                        </span>
+                      )}
+                      {result === 'pending' && <span className="badge">⏳ PENDING</span>}
+                    </td>
+                    <td className="py-3 pr-4 font-mono text-[var(--foreground-secondary)]">
+                      {time ? `${Math.floor(time / 60)}m ${time % 60}s` : '-'}
+                    </td>
+                    <td className="py-3 text-[var(--foreground-secondary)] truncate max-w-[200px]">
+                      {notes || '-'}
+                    </td>
+                  </tr>
+                )
+              })}
+            </tbody>
+          </table>
+        </div>
+
+        {/* Progress Bar */}
+        <div className="mt-6">
+          <div className="flex items-center justify-between text-sm mb-2">
+            <span>Overall Progress</span>
+            <span>{completedCount}/{totalCount} stories ({Math.round(progressPercent)}%)</span>
+          </div>
+          <div className="progress-bar">
+            <div className="progress-fill" style={{ width: `${progressPercent}%` }} />
+          </div>
+        </div>
+      </div>
+
+      {/* Live Output - Expanded */}
+      <div className="card">
+        <div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
+          <h3 className="font-medium flex items-center gap-2">
+            <span>📺</span>
+            Live Output
+            {isRunning && (
+              <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-500/20 text-green-400">
+                <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
+                LIVE
+              </span>
+            )}
+          </h3>
+          <div className="flex items-center gap-3">
+            <span className="text-xs text-[var(--foreground-tertiary)]">
+              {logs.length} lines
+            </span>
+            <button
+              onClick={() => setLogs([])}
+              className="text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] px-2 py-1 rounded hover:bg-[var(--surface-alt)]"
+            >
+              Clear
+            </button>
+            <button
+              onClick={() => {
+                const text = logs.join('\n')
+                const blob = new Blob([text], { type: 'text/plain' })
+                const url = URL.createObjectURL(blob)
+                const a = document.createElement('a')
+                a.href = url
+                a.download = 'ralph-log.txt'
+                a.click()
+              }}
+              className="text-sm text-[var(--foreground-secondary)] hover:text-[var(--foreground)] px-2 py-1 rounded hover:bg-[var(--surface-alt)]"
+            >
+              Export
+            </button>
+          </div>
+        </div>
+        <div className="p-4 bg-black/50 min-h-[400px] max-h-[600px] overflow-y-auto font-mono text-sm">
+          {logs.length === 0 ? (
+            <div className="text-[var(--foreground-tertiary)] text-center py-8">
+              No output yet. Click "START RALPHY BOY" to begin.
+            </div>
+          ) : (
+            logs.map((log, i) => {
+              // Determine log type and styling
+              const isSuccess = log.includes('✓') || log.includes('PASS') || log.includes('Completed') || log.includes('success')
+              const isError = log.includes('Error') || log.includes('FAIL') || log.includes('failed') || log.includes('❌')
+              const isWarning = log.includes('Warning') || log.includes('⚠')
+              const isRunningLog = log.includes('Running') || log.includes('Starting') || log.includes('...')
+              const isInfo = log.startsWith('> ') || log.startsWith('[')
+              const isFile = log.includes('.ts') || log.includes('.tsx') || log.includes('.js') || log.includes('.json')
+              const isCommand = log.startsWith('$') || log.startsWith('npm') || log.startsWith('git')
+              const isEmpty = log.trim() === ''
+
+              return (
+                <div
+                  key={i}
+                  className={`py-0.5 leading-relaxed ${
+                    isSuccess ? 'text-green-400' :
+                    isError ? 'text-red-400 font-medium' :
+                    isWarning ? 'text-yellow-400' :
+                    isRunningLog ? 'text-blue-400' :
+                    isFile ? 'text-purple-400' :
+                    isCommand ? 'text-cyan-400' :
+                    isInfo ? 'text-gray-300' :
+                    isEmpty ? '' :
+                    'text-gray-400'
+                  }`}
+                >
+                  {log}
+                </div>
+              )
+            })
+          )}
+          {isRunning && (
+            <div className="flex items-center gap-2 mt-2 text-blue-400">
+              <span className="inline-block w-2 h-4 bg-blue-400 animate-pulse">█</span>
+              <span className="text-xs animate-pulse">Processing...</span>
+            </div>
+          )}
+          <div ref={logsEndRef} />
+        </div>
+
+        {/* Log Legend */}
+        <div className="px-4 py-2 border-t border-[var(--border)] bg-[var(--surface-alt)] flex items-center gap-4 text-xs">
+          <span className="text-[var(--foreground-tertiary)]">Legend:</span>
+          <span className="text-green-400">● Success</span>
+          <span className="text-red-400">● Error</span>
+          <span className="text-yellow-400">● Warning</span>
+          <span className="text-blue-400">● Running</span>
+          <span className="text-purple-400">● Files</span>
+          <span className="text-cyan-400">● Commands</span>
+        </div>
+      </div>
+
+      {/* Files Modified */}
+      {status === 'completed' && (
+        <div className="card p-4">
+          <h3 className="font-medium mb-3 flex items-center gap-2">
+            <span>📁</span>
+            Files Modified
+          </h3>
+          <div className="space-y-1 text-sm font-mono">
+            <div className="text-[var(--success)]">• app/components/NewFeature.tsx (+34 lines)</div>
+            <div className="text-[var(--warning)]">• lib/utils.ts (modified)</div>
+            <div className="text-[var(--primary)]">• app/page.tsx (+5 lines)</div>
+          </div>
+        </div>
+      )}
+
+      {/* Actions */}
+      <div className="flex items-center justify-between p-4 card">
+        <button onClick={onBack} className="btn-secondary">
+          ← Back to Ralph
+        </button>
+        <div className="flex items-center gap-3">
+          <button
+            className="btn-secondary"
+            onClick={async () => {
+              if (!projectPath) return
+              try {
+                const progressPath = `${projectPath}/scripts/ralph/progress.txt`
+                const res = await fetch(`/api/files?path=${encodeURIComponent(progressPath)}`)
+                if (res.ok) {
+                  const data = await res.json()
+                  // Open in new window
+                  const win = window.open('', '_blank')
+                  if (win) {
+                    win.document.write(`<pre style="font-family:monospace;padding:20px;background:#1a1a24;color:#fafafa;">${data.content || 'No content'}</pre>`)
+                    win.document.title = 'progress.txt'
+                  }
+                } else {
+                  alert('Could not read progress.txt')
+                }
+              } catch (err) {
+                alert('Error reading progress.txt: ' + err)
+              }
+            }}
+          >
+            View progress.txt
+          </button>
+          <button
+            className="btn-primary"
+            disabled={status !== 'completed'}
+            onClick={() => {
+              // Open project in file manager or show summary
+              if (projectPath) {
+                window.open(`vscode://file${projectPath}`, '_blank')
+              }
+            }}
+          >
+            Open in VS Code
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/components/RecentItemsFooter.tsx b/components/RecentItemsFooter.tsx
new file mode 100644
index 0000000..9d1513a
--- /dev/null
+++ b/components/RecentItemsFooter.tsx
@@ -0,0 +1,278 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { Clock, FolderOpen, X, ChevronDown, ChevronUp, FileText } from 'lucide-react'
+
+export interface RecentItem {
+  name: string
+  path: string
+  timestamp: number
+  type: 'project' | 'file'
+  prdData?: {
+    featureTitle?: string
+    problemStatement?: string
+    scope?: string
+    priority?: string
+  }
+}
+
+interface RecentItemsFooterProps {
+  onItemClick?: (item: RecentItem) => void
+}
+
+const STORAGE_KEY = 'letsbegin_recent_items'
+const MAX_ITEMS = 10
+
+export function addRecentItem(item: Omit<RecentItem, 'timestamp'>) {
+  if (typeof window === 'undefined') return
+
+  const stored = localStorage.getItem(STORAGE_KEY)
+  let items: RecentItem[] = stored ? JSON.parse(stored) : []
+
+  // Remove if already exists
+  items = items.filter(i => i.path !== item.path)
+
+  // Add to front with timestamp
+  items.unshift({
+    ...item,
+    timestamp: Date.now()
+  })
+
+  // Keep only MAX_ITEMS
+  items = items.slice(0, MAX_ITEMS)
+
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
+
+  // Dispatch event for other components to listen
+  window.dispatchEvent(new CustomEvent('recentItemsUpdated'))
+}
+
+export function getRecentItems(): RecentItem[] {
+  if (typeof window === 'undefined') return []
+
+  const stored = localStorage.getItem(STORAGE_KEY)
+  return stored ? JSON.parse(stored) : []
+}
+
+export default function RecentItemsFooter({ onItemClick }: RecentItemsFooterProps) {
+  const [recentItems, setRecentItems] = useState<RecentItem[]>([])
+  const [isExpanded, setIsExpanded] = useState(false)
+  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set())
+
+  useEffect(() => {
+    // Load initial items
+    setRecentItems(getRecentItems())
+
+    // Listen for updates
+    const handleUpdate = () => {
+      setRecentItems(getRecentItems())
+    }
+
+    window.addEventListener('recentItemsUpdated', handleUpdate)
+    window.addEventListener('storage', handleUpdate)
+
+    return () => {
+      window.removeEventListener('recentItemsUpdated', handleUpdate)
+      window.removeEventListener('storage', handleUpdate)
+    }
+  }, [])
+
+  function removeItem(path: string, e: React.MouseEvent) {
+    e.stopPropagation()
+    const items = recentItems.filter(i => i.path !== path)
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
+    setRecentItems(items)
+    expandedItems.delete(path)
+    setExpandedItems(new Set(expandedItems))
+  }
+
+  function toggleItemExpand(path: string, e: React.MouseEvent) {
+    e.stopPropagation()
+    const newExpanded = new Set(expandedItems)
+    if (newExpanded.has(path)) {
+      newExpanded.delete(path)
+    } else {
+      newExpanded.add(path)
+    }
+    setExpandedItems(newExpanded)
+  }
+
+  function formatTime(timestamp: number): string {
+    const diff = Date.now() - timestamp
+    const minutes = Math.floor(diff / 60000)
+    const hours = Math.floor(diff / 3600000)
+
+    if (minutes < 1) return 'Just now'
+    if (minutes < 60) return `${minutes}m ago`
+    if (hours < 24) return `${hours}h ago`
+    return new Date(timestamp).toLocaleDateString()
+  }
+
+  function formatFullTime(timestamp: number): string {
+    return new Date(timestamp).toLocaleString()
+  }
+
+  if (recentItems.length === 0) return null
+
+  return (
+    <footer className={`flex-shrink-0 border-t border-[var(--border)] bg-[var(--surface-alt)] transition-all ${isExpanded ? 'max-h-[400px]' : 'max-h-16'}`}>
+      {/* Header - always visible */}
+      <div className="px-4 py-2 flex items-center justify-between">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => setIsExpanded(!isExpanded)}
+            className="flex items-center gap-1.5 text-xs text-[var(--foreground-tertiary)] hover:text-[var(--foreground)] transition-colors"
+          >
+            <Clock className="w-3 h-3" />
+            <span>Recent ({recentItems.length})</span>
+            {isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
+          </button>
+
+          {/* Quick access items (shown when collapsed) */}
+          {!isExpanded && (
+            <div className="flex items-center gap-2 overflow-x-auto">
+              {recentItems.slice(0, 5).map((item) => (
+                <button
+                  key={item.path}
+                  onClick={() => onItemClick?.(item)}
+                  className="group flex items-center gap-2 px-2 py-1 rounded-md bg-[var(--surface)] border border-[var(--border)] hover:border-[var(--primary)] hover:bg-[var(--primary)]/5 transition-all text-xs"
+                >
+                  <FolderOpen className="w-3 h-3 text-[var(--primary)]" />
+                  <span className="text-[var(--foreground)] font-medium max-w-[100px] truncate">
+                    {item.name}
+                  </span>
+                </button>
+              ))}
+              {recentItems.length > 5 && (
+                <span className="text-xs text-[var(--foreground-tertiary)]">
+                  +{recentItems.length - 5} more
+                </span>
+              )}
+            </div>
+          )}
+        </div>
+
+        <button
+          onClick={() => setIsExpanded(!isExpanded)}
+          className="text-xs px-2 py-1 rounded hover:bg-[var(--surface)] text-[var(--foreground-secondary)]"
+        >
+          {isExpanded ? 'Collapse' : 'Expand'}
+        </button>
+      </div>
+
+      {/* Expanded view with table */}
+      {isExpanded && (
+        <div className="px-4 pb-4 max-h-[340px] overflow-y-auto">
+          <table className="w-full text-sm">
+            <thead className="sticky top-0 bg-[var(--surface-alt)]">
+              <tr className="text-left text-xs text-[var(--foreground-tertiary)] border-b border-[var(--border)]">
+                <th className="pb-2 pr-3 w-8"></th>
+                <th className="pb-2 pr-3">Name</th>
+                <th className="pb-2 pr-3 hidden md:table-cell">Type</th>
+                <th className="pb-2 pr-3">Time</th>
+                <th className="pb-2 pr-3 hidden lg:table-cell">Path</th>
+                <th className="pb-2 w-20">Actions</th>
+              </tr>
+            </thead>
+            <tbody>
+              {recentItems.map((item, index) => {
+                const isItemExpanded = expandedItems.has(item.path)
+                return (
+                  <>
+                    <tr
+                      key={item.path}
+                      className="border-b border-[var(--border)]/50 hover:bg-[var(--surface)]/50 cursor-pointer"
+                      onClick={() => onItemClick?.(item)}
+                    >
+                      <td className="py-2 pr-3">
+                        <span className="text-[var(--foreground-tertiary)] text-xs">{index + 1}</span>
+                      </td>
+                      <td className="py-2 pr-3">
+                        <div className="flex items-center gap-2">
+                          {item.type === 'project' ? (
+                            <FolderOpen className="w-4 h-4 text-[var(--primary)]" />
+                          ) : (
+                            <FileText className="w-4 h-4 text-[var(--warning)]" />
+                          )}
+                          <span className="font-medium text-[var(--foreground)]">{item.name}</span>
+                        </div>
+                      </td>
+                      <td className="py-2 pr-3 hidden md:table-cell">
+                        <span className={`text-xs px-2 py-0.5 rounded-full ${
+                          item.type === 'project'
+                            ? 'bg-[var(--primary)]/20 text-[var(--primary)]'
+                            : 'bg-[var(--warning)]/20 text-[var(--warning)]'
+                        }`}>
+                          {item.type}
+                        </span>
+                      </td>
+                      <td className="py-2 pr-3">
+                        <span className="text-[var(--foreground-secondary)] text-xs">
+                          {formatTime(item.timestamp)}
+                        </span>
+                      </td>
+                      <td className="py-2 pr-3 hidden lg:table-cell">
+                        <span className="text-[var(--foreground-tertiary)] text-xs font-mono max-w-[200px] truncate block">
+                          {item.path}
+                        </span>
+                      </td>
+                      <td className="py-2">
+                        <div className="flex items-center gap-1">
+                          <button
+                            onClick={(e) => toggleItemExpand(item.path, e)}
+                            className="p-1 rounded hover:bg-[var(--surface)] text-[var(--foreground-secondary)] hover:text-[var(--foreground)]"
+                            title="Show details"
+                          >
+                            {isItemExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
+                          </button>
+                          <button
+                            onClick={(e) => removeItem(item.path, e)}
+                            className="p-1 rounded hover:bg-[var(--surface)] text-[var(--foreground-tertiary)] hover:text-[var(--error)]"
+                            title="Remove"
+                          >
+                            <X className="w-3 h-3" />
+                          </button>
+                        </div>
+                      </td>
+                    </tr>
+                    {/* Expanded details row */}
+                    {isItemExpanded && (
+                      <tr key={`${item.path}-details`} className="bg-[var(--surface)]/30">
+                        <td colSpan={6} className="py-3 px-4">
+                          <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
+                            <div>
+                              <span className="text-[var(--foreground-tertiary)]">Full Path:</span>
+                              <div className="font-mono text-[var(--foreground)] mt-1 break-all">{item.path}</div>
+                            </div>
+                            <div>
+                              <span className="text-[var(--foreground-tertiary)]">Accessed:</span>
+                              <div className="text-[var(--foreground)] mt-1">{formatFullTime(item.timestamp)}</div>
+                            </div>
+                            <div>
+                              <span className="text-[var(--foreground-tertiary)]">Type:</span>
+                              <div className="text-[var(--foreground)] mt-1 capitalize">{item.type}</div>
+                            </div>
+                            {item.prdData && (
+                              <div>
+                                <span className="text-[var(--foreground-tertiary)]">PRD Data:</span>
+                                <div className="text-[var(--foreground)] mt-1">
+                                  {item.prdData.featureTitle && <div>Feature: {item.prdData.featureTitle}</div>}
+                                  {item.prdData.scope && <div>Scope: {item.prdData.scope}</div>}
+                                  {item.prdData.priority && <div>Priority: {item.prdData.priority}</div>}
+                                </div>
+                              </div>
+                            )}
+                          </div>
+                        </td>
+                      </tr>
+                    )}
+                  </>
+                )
+              })}
+            </tbody>
+          </table>
+        </div>
+      )}
+    </footer>
+  )
+}
diff --git a/components/SimplifiedQuestionnaire.tsx b/components/SimplifiedQuestionnaire.tsx
new file mode 100644
index 0000000..028ea8d
--- /dev/null
+++ b/components/SimplifiedQuestionnaire.tsx
@@ -0,0 +1,628 @@
+'use client'
+
+import { useState } from 'react'
+import { Project, PRDData } from '@/app/page'
+
+interface Question {
+  id: string
+  title: string
+  subtitle: string
+  field: keyof PRDData | 'competitorUrls' | 'appStoreUrls' | 'researchOptions'
+  type: 'text' | 'textarea' | 'select' | 'multiselect' | 'url-list' | 'checkbox-grid'
+  options?: { value: string; label: string; icon?: string; desc?: string }[]
+  placeholder?: string
+  required?: boolean
+}
+
+const questions: Question[] = [
+  {
+    id: 'projectName',
+    title: 'What project are you working on?',
+    subtitle: 'Select the project from your Projects folder',
+    field: 'projectName',
+    type: 'text',
+    placeholder: 'Project name...',
+    required: true
+  },
+  {
+    id: 'featureTitle',
+    title: 'What feature are you building?',
+    subtitle: 'Give your feature a short, descriptive title',
+    field: 'featureTitle',
+    type: 'text',
+    placeholder: 'e.g., User Authentication System',
+    required: true
+  },
+  {
+    id: 'problemStatement',
+    title: 'What problem does this solve?',
+    subtitle: 'Describe the issue or need this feature addresses',
+    field: 'problemStatement',
+    type: 'textarea',
+    placeholder: 'Users currently have to... but they need...',
+    required: true
+  },
+  {
+    id: 'targetUser',
+    title: 'Who is this for?',
+    subtitle: 'Select the primary user type',
+    field: 'targetUser',
+    type: 'select',
+    options: [
+      { value: 'A', label: 'New users - First time visitors' },
+      { value: 'B', label: 'Existing users - Regular customers' },
+      { value: 'C', label: 'Admin users - Internal team' },
+      { value: 'D', label: 'All users - Everyone' }
+    ]
+  },
+  {
+    id: 'scope',
+    title: 'How big is this feature?',
+    subtitle: 'Choose the scope of implementation',
+    field: 'scope',
+    type: 'select',
+    options: [
+      { value: 'A', label: 'Minimal - Quick MVP, bare essentials' },
+      { value: 'B', label: 'Full-featured - Complete implementation' },
+      { value: 'C', label: 'Backend only - APIs and data' },
+      { value: 'D', label: 'Frontend only - UI components' }
+    ]
+  },
+  {
+    id: 'priority',
+    title: 'How urgent is this?',
+    subtitle: 'Set the priority level',
+    field: 'priority',
+    type: 'select',
+    options: [
+      { value: 'A', label: 'Critical - ASAP, blocking other work' },
+      { value: 'B', label: 'High - Important, do soon' },
+      { value: 'C', label: 'Medium - Standard priority' },
+      { value: 'D', label: 'Low - Nice to have' }
+    ]
+  },
+  {
+    id: 'keyFeatures',
+    title: 'What are the key features?',
+    subtitle: 'List the main capabilities (one per line)',
+    field: 'keyFeatures',
+    type: 'textarea',
+    placeholder: '• User can sign up with email\n• Password reset via email\n• Remember me option'
+  },
+  {
+    id: 'nonGoals',
+    title: 'What should it NOT do?',
+    subtitle: 'Define boundaries and exclusions',
+    field: 'nonGoals',
+    type: 'textarea',
+    placeholder: '• No social login in v1\n• No 2FA initially'
+  },
+  {
+    id: 'successMetrics',
+    title: 'How will you measure success?',
+    subtitle: 'Define specific, measurable outcomes',
+    field: 'successMetrics',
+    type: 'textarea',
+    placeholder: '• Login takes < 2 seconds\n• 99% uptime\n• Zero security breaches'
+  },
+  {
+    id: 'integrations',
+    title: 'What integrations are needed?',
+    subtitle: 'APIs, services, or third-party tools',
+    field: 'integrations',
+    type: 'textarea',
+    placeholder: 'e.g., Stripe for payments, SendGrid for email'
+  },
+  {
+    id: 'techStack',
+    title: 'What tech stack?',
+    subtitle: 'Framework and technology preferences',
+    field: 'techStack',
+    type: 'text',
+    placeholder: 'Next.js 16 + React 19'
+  },
+  {
+    id: 'competitorUrls',
+    title: 'Any apps or websites for inspiration?',
+    subtitle: 'Add URLs to similar apps or competitors',
+    field: 'competitorUrls',
+    type: 'url-list',
+    placeholder: 'https://example.com'
+  },
+  {
+    id: 'appStoreUrls',
+    title: 'Similar mobile apps?',
+    subtitle: 'Links to App Store or Play Store apps for reference',
+    field: 'appStoreUrls',
+    type: 'url-list',
+    placeholder: 'https://apps.apple.com/...'
+  },
+  {
+    id: 'researchOptions',
+    title: 'What should we research?',
+    subtitle: 'Select sources to explore for inspiration',
+    field: 'researchOptions',
+    type: 'checkbox-grid',
+    options: [
+      { value: 'searchGithub', label: 'GitHub', icon: '📦', desc: 'Similar projects' },
+      { value: 'searchPublicDBs', label: 'Public APIs', icon: '🗄️', desc: 'APIs & datasets' },
+      { value: 'searchSkills', label: 'Skills', icon: '🎯', desc: 'Reusable skills' },
+      { value: 'searchAgents', label: 'Agents', icon: '🤖', desc: 'Pre-built agents' },
+      { value: 'searchAppStore', label: 'App Store', icon: '🍎', desc: 'iOS apps' },
+      { value: 'searchPlayStore', label: 'Play Store', icon: '📱', desc: 'Android apps' }
+    ]
+  }
+]
+
+interface Props {
+  prdData: PRDData
+  setPrdData: React.Dispatch<React.SetStateAction<PRDData>>
+  selectedProject: Project | null
+  onComplete: () => void
+  onCancel: () => void
+}
+
+export default function SimplifiedQuestionnaire({
+  prdData,
+  setPrdData,
+  selectedProject,
+  onComplete,
+  onCancel
+}: Props) {
+  const [currentIndex, setCurrentIndex] = useState(0)
+  const [suggestion, setSuggestion] = useState<string | null>(null)
+  const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false)
+  const [urlInfoLoading, setUrlInfoLoading] = useState<Record<number, boolean>>({})
+  const [urlInfo, setUrlInfo] = useState<Record<number, { title: string; description: string; features: string[] } | null>>({})
+
+  const handleLoadUrlInfo = async (url: string, index: number) => {
+    if (!url || !url.startsWith('http')) return
+
+    setUrlInfoLoading(prev => ({ ...prev, [index]: true }))
+    try {
+      const res = await fetch('/api/url-info', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ url })
+      })
+
+      if (res.ok) {
+        const data = await res.json()
+        setUrlInfo(prev => ({ ...prev, [index]: data }))
+      } else {
+        setUrlInfo(prev => ({ ...prev, [index]: null }))
+      }
+    } catch (err) {
+      console.error('URL info error:', err)
+      setUrlInfo(prev => ({ ...prev, [index]: null }))
+    } finally {
+      setUrlInfoLoading(prev => ({ ...prev, [index]: false }))
+    }
+  }
+
+  const handleAddToField = (info: { title: string; description: string; features: string[] }, field: 'keyFeatures' | 'problemStatement') => {
+    if (field === 'keyFeatures') {
+      const existing = prdData.keyFeatures || ''
+      const newFeatures = info.features.map(f => `• ${f}`).join('\n')
+      const combined = existing ? `${existing}\n\n📌 From ${info.title}:\n${newFeatures}` : `📌 From ${info.title}:\n${newFeatures}`
+      setPrdData(prev => ({ ...prev, keyFeatures: combined }))
+    } else if (field === 'problemStatement') {
+      const existing = prdData.problemStatement || ''
+      const combined = existing ? `${existing}\n\n📌 Reference (${info.title}): ${info.description}` : `📌 Reference (${info.title}): ${info.description}`
+      setPrdData(prev => ({ ...prev, problemStatement: combined }))
+    }
+  }
+
+  const currentQuestion = questions[currentIndex]
+  const progress = ((currentIndex + 1) / questions.length) * 100
+
+  const handleChange = (value: string | string[]) => {
+    setPrdData(prev => ({
+      ...prev,
+      [currentQuestion.field]: value
+    }))
+  }
+
+  const getValue = (): string => {
+    const val = prdData[currentQuestion.field as keyof PRDData]
+    if (Array.isArray(val)) return val.join(', ')
+    if (typeof val === 'object' && val !== null) return '' // For researchOptions
+    return (val as string) || ''
+  }
+
+  const canProceed = () => {
+    if (!currentQuestion.required) return true
+    // URL lists and checkbox grids are optional
+    if (currentQuestion.type === 'url-list' || currentQuestion.type === 'checkbox-grid') return true
+    const value = getValue()
+    return value.trim().length > 0
+  }
+
+  const handleNext = () => {
+    setSuggestion(null)
+    if (currentIndex < questions.length - 1) {
+      setCurrentIndex(currentIndex + 1)
+    } else {
+      onComplete()
+    }
+  }
+
+  const handleBack = () => {
+    setSuggestion(null)
+    if (currentIndex > 0) {
+      setCurrentIndex(currentIndex - 1)
+    }
+  }
+
+  const handleGetSuggestion = async () => {
+    setIsLoadingSuggestion(true)
+    try {
+      const context = {
+        projectName: prdData.projectName || selectedProject?.name || 'Unknown Project',
+        currentField: currentQuestion.field,
+        currentQuestion: currentQuestion.title,
+        existingData: prdData
+      }
+
+      const res = await fetch('/api/claude/suggest', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(context)
+      })
+
+      if (res.ok) {
+        const data = await res.json()
+        setSuggestion(data.suggestion)
+      } else {
+        setSuggestion('Unable to get suggestion. Please try again.')
+      }
+    } catch (err) {
+      console.error('Suggestion error:', err)
+      setSuggestion('Error getting suggestion. Check your connection.')
+    } finally {
+      setIsLoadingSuggestion(false)
+    }
+  }
+
+  const applySuggestion = () => {
+    if (suggestion) {
+      // For url-list fields, parse the suggestion to extract URLs
+      if (currentQuestion.type === 'url-list') {
+        // Extract URLs from the formatted suggestion
+        // Matches URLs like https://example.com or http://example.com
+        const urlRegex = /https?:\/\/[^\s\)]+/g
+        const matches = suggestion.match(urlRegex)
+        if (matches && matches.length > 0) {
+          // Clean up URLs (remove trailing punctuation)
+          const urls = matches.map(url => url.replace(/[,\.\)\]]+$/, ''))
+          handleChange(urls)
+        } else {
+          // Fallback: split by newlines and filter for URL-like strings
+          const lines = suggestion.split('\n').filter(line => line.includes('http'))
+          const urls = lines.map(line => {
+            const match = line.match(/https?:\/\/[^\s\)]+/)
+            return match ? match[0].replace(/[,\.\)\]]+$/, '') : ''
+          }).filter(Boolean)
+          if (urls.length > 0) {
+            handleChange(urls)
+          }
+        }
+      } else {
+        handleChange(suggestion)
+      }
+      setSuggestion(null)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 bg-[var(--background)] z-[9999] flex flex-col overflow-y-auto">
+      {/* Progress bar */}
+      <div className="h-2 bg-[var(--surface-alt)] flex-shrink-0">
+        <div
+          className="h-full bg-gradient-to-r from-[var(--primary)] to-[var(--secondary)] transition-all duration-300"
+          style={{ width: `${progress}%` }}
+        />
+      </div>
+
+      {/* Header - centered */}
+      <div className="relative flex items-center justify-center px-8 py-4 border-b border-[var(--border)] flex-shrink-0">
+        <button
+          onClick={onCancel}
+          className="absolute left-8 text-[var(--foreground-secondary)] hover:text-[var(--foreground)] transition-colors"
+        >
+          <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </button>
+        <div className="text-lg font-medium text-[var(--foreground)]">
+          Question {currentIndex + 1} of {questions.length}
+        </div>
+      </div>
+
+      {/* Main content - fully centered with max-width container */}
+      <div className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-8 w-full">
+        <div className="w-full max-w-2xl mx-auto flex flex-col items-center">
+        {/* Question title */}
+        <h1 className="text-4xl md:text-5xl font-bold text-center mb-4 leading-tight">
+          {currentQuestion.title}
+        </h1>
+
+        {/* Subtitle */}
+        <p className="text-xl text-[var(--foreground-secondary)] text-center mb-8">
+          {currentQuestion.subtitle}
+        </p>
+
+        {/* Input field */}
+        <div className="w-full">
+          {currentQuestion.type === 'text' && (
+            <input
+              type="text"
+              value={getValue()}
+              onChange={(e) => handleChange(e.target.value)}
+              placeholder={currentQuestion.placeholder}
+              className="w-full text-2xl p-6 rounded-xl bg-[var(--surface)] border-2 border-[var(--border)] focus:border-[var(--primary)] focus:outline-none transition-colors text-center"
+              autoFocus
+            />
+          )}
+
+          {currentQuestion.type === 'textarea' && (
+            <textarea
+              value={getValue()}
+              onChange={(e) => handleChange(e.target.value)}
+              placeholder={currentQuestion.placeholder}
+              rows={5}
+              className="w-full text-xl p-6 rounded-xl bg-[var(--surface)] border-2 border-[var(--border)] focus:border-[var(--primary)] focus:outline-none transition-colors resize-none"
+              autoFocus
+            />
+          )}
+
+          {currentQuestion.type === 'select' && currentQuestion.options && (
+            <div className="space-y-3">
+              {currentQuestion.options.map((option) => (
+                <button
+                  key={option.value}
+                  onClick={() => handleChange(option.value)}
+                  className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
+                    getValue() === option.value
+                      ? 'border-[var(--primary)] bg-[var(--primary)]/10'
+                      : 'border-[var(--border)] bg-[var(--surface)] hover:border-[var(--primary)]/50'
+                  }`}
+                >
+                  <span className="text-xl font-medium">{option.label}</span>
+                </button>
+              ))}
+            </div>
+          )}
+
+          {currentQuestion.type === 'url-list' && (
+            <div className="space-y-4">
+              {((prdData[currentQuestion.field as keyof PRDData] as string[]) || ['']).map((url, index) => (
+                <div key={index} className="space-y-2">
+                  <div className="flex gap-2">
+                    <input
+                      type="url"
+                      value={url}
+                      onChange={(e) => {
+                        const urls = [...((prdData[currentQuestion.field as keyof PRDData] as string[]) || [''])]
+                        urls[index] = e.target.value
+                        setPrdData(prev => ({ ...prev, [currentQuestion.field]: urls }))
+                        // Clear loaded info when URL changes
+                        setUrlInfo(prev => ({ ...prev, [index]: null }))
+                      }}
+                      placeholder={currentQuestion.placeholder}
+                      className="flex-1 text-xl p-4 rounded-xl bg-[var(--surface)] border-2 border-[var(--border)] focus:border-[var(--primary)] focus:outline-none transition-colors"
+                      autoFocus={index === 0}
+                    />
+                    {/* Load Info Button */}
+                    {url && url.startsWith('http') && (
+                      <button
+                        onClick={() => handleLoadUrlInfo(url, index)}
+                        disabled={urlInfoLoading[index]}
+                        className="px-4 py-2 rounded-xl bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 text-green-400 font-medium hover:from-green-500/30 hover:to-emerald-500/30 disabled:opacity-50 transition-all whitespace-nowrap"
+                      >
+                        {urlInfoLoading[index] ? (
+                          <span className="flex items-center gap-2">
+                            <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
+                              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                            </svg>
+                            Loading...
+                          </span>
+                        ) : urlInfo[index] ? '✓ Loaded' : '🔍 Load'}
+                      </button>
+                    )}
+                    {index === ((prdData[currentQuestion.field as keyof PRDData] as string[]) || ['']).length - 1 ? (
+                      <button
+                        onClick={() => {
+                          const urls = [...((prdData[currentQuestion.field as keyof PRDData] as string[]) || [''])]
+                          setPrdData(prev => ({ ...prev, [currentQuestion.field]: [...urls, ''] }))
+                        }}
+                        className="px-4 py-2 rounded-xl bg-[var(--primary)] text-white font-medium hover:opacity-90"
+                      >
+                        + Add
+                      </button>
+                    ) : (
+                      <button
+                        onClick={() => {
+                          const urls = ((prdData[currentQuestion.field as keyof PRDData] as string[]) || ['']).filter((_, i) => i !== index)
+                          setPrdData(prev => ({ ...prev, [currentQuestion.field]: urls.length ? urls : [''] }))
+                          // Clear loaded info for removed URL
+                          setUrlInfo(prev => {
+                            const newInfo = { ...prev }
+                            delete newInfo[index]
+                            return newInfo
+                          })
+                        }}
+                        className="px-4 py-2 rounded-xl bg-red-500/20 text-red-400 font-medium hover:bg-red-500/30"
+                      >
+                        Remove
+                      </button>
+                    )}
+                  </div>
+
+                  {/* Loaded URL Info Display */}
+                  {urlInfo[index] && (
+                    <div className="ml-2 p-4 rounded-xl bg-[var(--surface-alt)] border border-[var(--border)]">
+                      <div className="flex items-start justify-between gap-4">
+                        <div className="flex-1 min-w-0">
+                          <h4 className="font-semibold text-lg text-[var(--foreground)] truncate">{urlInfo[index]?.title || 'Untitled'}</h4>
+                          <p className="text-sm text-[var(--foreground-secondary)] mt-1 line-clamp-2">{urlInfo[index]?.description || 'No description'}</p>
+                          {urlInfo[index]?.features && urlInfo[index]!.features.length > 0 && (
+                            <div className="mt-2 flex flex-wrap gap-1">
+                              {urlInfo[index]!.features.slice(0, 5).map((f, i) => (
+                                <span key={i} className="px-2 py-1 text-xs rounded-full bg-[var(--primary)]/20 text-[var(--primary)]">{f}</span>
+                              ))}
+                              {urlInfo[index]!.features.length > 5 && (
+                                <span className="px-2 py-1 text-xs rounded-full bg-[var(--border)] text-[var(--foreground-tertiary)]">+{urlInfo[index]!.features.length - 5} more</span>
+                              )}
+                            </div>
+                          )}
+                        </div>
+                        <div className="flex flex-col gap-2">
+                          <button
+                            onClick={() => handleAddToField(urlInfo[index]!, 'keyFeatures')}
+                            className="px-3 py-2 text-sm rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors whitespace-nowrap"
+                          >
+                            + Add to Features
+                          </button>
+                          <button
+                            onClick={() => handleAddToField(urlInfo[index]!, 'problemStatement')}
+                            className="px-3 py-2 text-sm rounded-lg bg-purple-500/20 text-purple-400 hover:bg-purple-500/30 transition-colors whitespace-nowrap"
+                          >
+                            + Add to Problem
+                          </button>
+                        </div>
+                      </div>
+                    </div>
+                  )}
+                </div>
+              ))}
+
+              {/* Search Claude for competitor suggestions button */}
+              <button
+                onClick={handleGetSuggestion}
+                disabled={isLoadingSuggestion}
+                className="w-full mt-4 flex items-center justify-center gap-3 px-6 py-4 rounded-xl bg-gradient-to-r from-purple-500/20 to-blue-500/20 border-2 border-purple-500/30 text-purple-300 hover:from-purple-500/30 hover:to-blue-500/30 hover:border-purple-500/50 transition-all disabled:opacity-50"
+              >
+                {isLoadingSuggestion ? (
+                  <>
+                    <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
+                      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                    </svg>
+                    <span className="text-lg font-medium">Searching for inspiration...</span>
+                  </>
+                ) : (
+                  <>
+                    <span className="text-2xl">🔍</span>
+                    <span className="text-lg font-medium">Search Claude for Suggestions</span>
+                  </>
+                )}
+              </button>
+
+              <p className="text-sm text-[var(--foreground-tertiary)] text-center mt-2">
+                Skip this step if you don't have any URLs
+              </p>
+            </div>
+          )}
+
+          {currentQuestion.type === 'checkbox-grid' && currentQuestion.options && (
+            <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
+              {currentQuestion.options.map((option) => {
+                const researchOpts = prdData.researchOptions || {}
+                const isChecked = researchOpts[option.value as keyof typeof researchOpts] || false
+                return (
+                  <button
+                    key={option.value}
+                    onClick={() => {
+                      setPrdData(prev => ({
+                        ...prev,
+                        researchOptions: {
+                          ...prev.researchOptions,
+                          [option.value]: !isChecked
+                        }
+                      }))
+                    }}
+                    className={`p-4 rounded-xl border-2 transition-all text-left ${
+                      isChecked
+                        ? 'border-[var(--primary)] bg-[var(--primary)]/10'
+                        : 'border-[var(--border)] bg-[var(--surface)] hover:border-[var(--primary)]/50'
+                    }`}
+                  >
+                    <div className="flex items-center gap-3">
+                      <span className="text-2xl">{option.icon}</span>
+                      <div>
+                        <div className="font-medium">{option.label}</div>
+                        <div className="text-sm text-[var(--foreground-tertiary)]">{option.desc}</div>
+                      </div>
+                    </div>
+                  </button>
+                )
+              })}
+            </div>
+          )}
+        </div>
+
+        {/* Claude suggestion button */}
+        <button
+          onClick={handleGetSuggestion}
+          disabled={isLoadingSuggestion}
+          className="mt-8 flex items-center gap-2 px-6 py-3 rounded-full bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30 text-blue-400 hover:from-blue-500/30 hover:to-purple-500/30 transition-all disabled:opacity-50"
+        >
+          {isLoadingSuggestion ? (
+            <>
+              <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
+                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+              </svg>
+              <span>Thinking...</span>
+            </>
+          ) : (
+            <>
+              <span className="text-lg">💡</span>
+              <span className="font-medium">Get Claude's Suggestion</span>
+            </>
+          )}
+        </button>
+
+        {/* Suggestion box */}
+        {suggestion && (
+          <div className="mt-6 w-full max-w-xl p-5 rounded-xl bg-blue-500/10 border border-blue-500/30">
+            <div className="flex items-start gap-3">
+              <span className="text-2xl">💡</span>
+              <div className="flex-1">
+                <p className="text-blue-300 whitespace-pre-wrap">{suggestion}</p>
+                <button
+                  onClick={applySuggestion}
+                  className="mt-3 px-4 py-2 rounded-lg bg-blue-500 text-white font-medium hover:bg-blue-600 transition-colors"
+                >
+                  Use This Suggestion
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* Navigation buttons - underneath input field */}
+        <div className="flex items-center justify-center gap-4 mt-10 w-full">
+          <button
+            onClick={handleBack}
+            disabled={currentIndex === 0}
+            className="flex-1 max-w-[200px] px-6 py-4 rounded-xl text-lg font-medium border-2 border-[var(--border)] text-[var(--foreground-secondary)] hover:text-[var(--foreground)] hover:border-[var(--foreground-secondary)] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
+          >
+            ← Back
+          </button>
+
+          <button
+            onClick={handleNext}
+            disabled={!canProceed()}
+            className="flex-1 max-w-[200px] px-8 py-4 rounded-xl text-lg font-medium bg-gradient-to-r from-[var(--primary)] to-[var(--secondary)] text-white hover:opacity-90 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
+          >
+            {currentIndex === questions.length - 1 ? '📄 Submit' : 'Next →'}
+          </button>
+        </div>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/components/StepTabs.tsx b/components/StepTabs.tsx
new file mode 100644
index 0000000..7714cb8
--- /dev/null
+++ b/components/StepTabs.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+interface Props {
+  currentStep: number
+  stepStatuses: Record<number, string>
+  onStepClick: (step: number) => void
+}
+
+const steps = [
+  { number: 1, icon: '📝', label: 'PRD Generator' },
+  { number: 2, icon: '🔄', label: 'Ralph Convert' },
+  { number: 3, icon: '🤖', label: 'Run Ralphy Boy' },
+]
+
+export default function StepTabs({ currentStep, stepStatuses, onStepClick }: Props) {
+  return (
+    <div className="flex items-center bg-[var(--surface)] border border-[var(--border)] rounded-xl overflow-hidden">
+      {steps.map((step, index) => {
+        const status = stepStatuses[step.number]
+        const isActive = currentStep === step.number
+        const isCompleted = status === 'completed'
+        const isPending = status === 'pending'
+
+        return (
+          <button
+            key={step.number}
+            onClick={() => onStepClick(step.number)}
+            disabled={isPending}
+            className={`flex-1 flex items-center justify-center gap-3 py-4 px-6 transition-all relative
+              ${isActive ? 'bg-[var(--surface-alt)]' : ''}
+              ${isCompleted ? 'text-[var(--success)]' : ''}
+              ${isPending ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--surface-alt)] cursor-pointer'}
+            `}
+          >
+            {/* Step Number/Check */}
+            <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
+              ${isCompleted ? 'bg-[var(--success)] text-white' : ''}
+              ${isActive && !isCompleted ? 'bg-[var(--primary)] text-white' : ''}
+              ${isPending ? 'bg-[var(--surface-elevated)] text-[var(--foreground-tertiary)]' : ''}
+            `}>
+              {isCompleted ? (
+                <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
+                </svg>
+              ) : (
+                step.number
+              )}
+            </div>
+
+            {/* Label */}
+            <div className="flex items-center gap-2">
+              <span className="text-lg">{step.icon}</span>
+              <span className="font-medium hidden sm:inline">{step.label}</span>
+            </div>
+
+            {/* Active Indicator */}
+            {isActive && (
+              <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--primary)]" />
+            )}
+
+            {/* Connector */}
+            {index < steps.length - 1 && (
+              <div className="absolute right-0 top-1/2 -translate-y-1/2 w-px h-8 bg-[var(--border)]" />
+            )}
+          </button>
+        )
+      })}
+    </div>
+  )
+}
diff --git a/components/ui/SidebarDemo.tsx b/components/ui/SidebarDemo.tsx
new file mode 100644
index 0000000..da160dc
--- /dev/null
+++ b/components/ui/SidebarDemo.tsx
@@ -0,0 +1,221 @@
+"use client";
+import React, { useState } from "react";
+import { Sidebar, SidebarBody, SidebarLink } from "@/components/ui/sidebar";
+import {
+  FileText,
+  RefreshCw,
+  Play,
+  FolderOpen,
+  Settings,
+  LogOut,
+  Rocket,
+  Zap
+} from "lucide-react";
+import Link from "next/link";
+import { motion } from "framer-motion";
+import { cn } from "@/lib/utils";
+
+interface SidebarDemoProps {
+  currentStep: number;
+  onStepChange: (step: number) => void;
+  onLogout: () => void;
+  username?: string;
+  children: React.ReactNode;
+}
+
+export function SidebarDemo({
+  currentStep,
+  onStepChange,
+  onLogout,
+  username = "Admin",
+  children
+}: SidebarDemoProps) {
+  const links = [
+    {
+      label: "Quick Mode",
+      href: "#",
+      icon: (
+        <Zap className={cn(
+          "h-5 w-5 flex-shrink-0",
+          currentStep === 0 ? "text-[var(--primary)]" : "text-neutral-700 dark:text-neutral-200"
+        )} />
+      ),
+      step: 0,
+    },
+    {
+      label: "PRD Generator",
+      href: "#",
+      icon: (
+        <FileText className={cn(
+          "h-5 w-5 flex-shrink-0",
+          currentStep === 1 ? "text-[var(--primary)]" : "text-neutral-700 dark:text-neutral-200"
+        )} />
+      ),
+      step: 1,
+    },
+    {
+      label: "Ralph Converter",
+      href: "#",
+      icon: (
+        <RefreshCw className={cn(
+          "h-5 w-5 flex-shrink-0",
+          currentStep === 2 ? "text-[var(--primary)]" : "text-neutral-700 dark:text-neutral-200"
+        )} />
+      ),
+      step: 2,
+    },
+    {
+      label: "Ralphy Boy Runner",
+      href: "#",
+      icon: (
+        <Play className={cn(
+          "h-5 w-5 flex-shrink-0",
+          currentStep === 3 ? "text-[var(--primary)]" : "text-neutral-700 dark:text-neutral-200"
+        )} />
+      ),
+      step: 3,
+    },
+  ];
+
+  const bottomLinks = [
+    {
+      label: "Projects",
+      href: "#",
+      icon: (
+        <FolderOpen className="text-neutral-700 dark:text-neutral-200 h-5 w-5 flex-shrink-0" />
+      ),
+    },
+    {
+      label: "Settings",
+      href: "#",
+      icon: (
+        <Settings className="text-neutral-700 dark:text-neutral-200 h-5 w-5 flex-shrink-0" />
+      ),
+    },
+  ];
+
+  const [open, setOpen] = useState(false);
+
+  return (
+    <div
+      className={cn(
+        "flex flex-col md:flex-row bg-[var(--background)] w-full flex-1 border border-[var(--border)] overflow-hidden",
+        "h-screen"
+      )}
+    >
+      <Sidebar open={open} setOpen={setOpen}>
+        <SidebarBody className="justify-between gap-10 bg-[var(--surface)] border-r border-[var(--border)]">
+          <div className="flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
+            {open ? <Logo /> : <LogoIcon />}
+
+            {/* Step Navigation */}
+            <div className="mt-8 flex flex-col gap-2">
+              <div className="text-xs text-[var(--foreground-tertiary)] px-2 mb-2">
+                {open && "WORKFLOW"}
+              </div>
+              {links.map((link, idx) => (
+                <button
+                  key={idx}
+                  onClick={() => onStepChange(link.step)}
+                  className={cn(
+                    "flex items-center justify-start gap-2 group/sidebar py-2 px-2 rounded-lg transition-colors",
+                    currentStep === link.step
+                      ? "bg-[var(--primary)]/20 text-[var(--primary)]"
+                      : "hover:bg-[var(--surface-alt)]"
+                  )}
+                >
+                  {link.icon}
+                  <motion.span
+                    animate={{
+                      display: open ? "inline-block" : "none",
+                      opacity: open ? 1 : 0,
+                    }}
+                    className={cn(
+                      "text-sm group-hover/sidebar:translate-x-1 transition duration-150 whitespace-pre inline-block !p-0 !m-0",
+                      currentStep === link.step
+                        ? "text-[var(--primary)] font-medium"
+                        : "text-[var(--foreground)]"
+                    )}
+                  >
+                    {link.label}
+                  </motion.span>
+                </button>
+              ))}
+            </div>
+
+            {/* Bottom Links */}
+            <div className="mt-auto flex flex-col gap-2">
+              <div className="text-xs text-[var(--foreground-tertiary)] px-2 mb-2">
+                {open && "TOOLS"}
+              </div>
+              {bottomLinks.map((link, idx) => (
+                <SidebarLink key={idx} link={link} />
+              ))}
+            </div>
+          </div>
+
+          {/* User Section */}
+          <div className="border-t border-[var(--border)] pt-4">
+            <button
+              onClick={onLogout}
+              className="flex items-center justify-start gap-2 group/sidebar py-2 px-2 rounded-lg hover:bg-red-500/10 transition-colors w-full"
+            >
+              <LogOut className="text-red-400 h-5 w-5 flex-shrink-0" />
+              <motion.span
+                animate={{
+                  display: open ? "inline-block" : "none",
+                  opacity: open ? 1 : 0,
+                }}
+                className="text-red-400 text-sm whitespace-pre inline-block !p-0 !m-0"
+              >
+                Logout ({username})
+              </motion.span>
+            </button>
+          </div>
+        </SidebarBody>
+      </Sidebar>
+
+      {/* Main Content */}
+      <div className="flex flex-1 overflow-hidden">
+        <div className="flex-1 overflow-auto">
+          {children}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export const Logo = () => {
+  return (
+    <Link
+      href="#"
+      className="font-normal flex space-x-2 items-center text-sm py-1 relative z-20"
+    >
+      <div className="h-6 w-6 bg-gradient-to-br from-[var(--primary)] to-[var(--secondary)] rounded-lg flex items-center justify-center flex-shrink-0">
+        <Rocket className="h-4 w-4 text-white" />
+      </div>
+      <motion.span
+        initial={{ opacity: 0 }}
+        animate={{ opacity: 1 }}
+        className="font-bold text-[var(--foreground)] whitespace-pre"
+      >
+        GoodQuestionRalph
+      </motion.span>
+    </Link>
+  );
+};
+
+export const LogoIcon = () => {
+  return (
+    <Link
+      href="#"
+      className="font-normal flex space-x-2 items-center text-sm py-1 relative z-20"
+    >
+      <div className="h-6 w-6 bg-gradient-to-br from-[var(--primary)] to-[var(--secondary)] rounded-lg flex items-center justify-center flex-shrink-0">
+        <Rocket className="h-4 w-4 text-white" />
+      </div>
+    </Link>
+  );
+};
+
+export default SidebarDemo;
diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx
new file mode 100644
index 0000000..3200181
--- /dev/null
+++ b/components/ui/sidebar.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import Link, { LinkProps } from "next/link";
+import React, { useState, createContext, useContext } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { Menu, X } from "lucide-react";
+
+interface Links {
+  label: string;
+  href: string;
+  icon: React.JSX.Element | React.ReactNode;
+}
+
+interface SidebarContextProps {
+  open: boolean;
+  setOpen: React.Dispatch<React.SetStateAction<boolean>>;
+  animate: boolean;
+}
+
+const SidebarContext = createContext<SidebarContextProps | undefined>(
+  undefined
+);
+
+export const useSidebar = () => {
+  const context = useContext(SidebarContext);
+  if (!context) {
+    throw new Error("useSidebar must be used within a SidebarProvider");
+  }
+  return context;
+};
+
+export const SidebarProvider = ({
+  children,
+  open: openProp,
+  setOpen: setOpenProp,
+  animate = true,
+}: {
+  children: React.ReactNode;
+  open?: boolean;
+  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
+  animate?: boolean;
+}) => {
+  const [openState, setOpenState] = useState(false);
+
+  const open = openProp !== undefined ? openProp : openState;
+  const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState;
+
+  return (
+    <SidebarContext.Provider value={{ open, setOpen, animate }}>
+      {children}
+    </SidebarContext.Provider>
+  );
+};
+
+export const Sidebar = ({
+  children,
+  open,
+  setOpen,
+  animate,
+}: {
+  children: React.ReactNode;
+  open?: boolean;
+  setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
+  animate?: boolean;
+}) => {
+  return (
+    <SidebarProvider open={open} setOpen={setOpen} animate={animate}>
+      {children}
+    </SidebarProvider>
+  );
+};
+
+export const SidebarBody = (props: React.ComponentProps<typeof motion.div>) => {
+  return (
+    <>
+      <DesktopSidebar {...props} />
+      <MobileSidebar {...(props as React.ComponentProps<"div">)} />
+    </>
+  );
+};
+
+export const DesktopSidebar = ({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof motion.div>) => {
+  const { open, setOpen, animate } = useSidebar();
+  return (
+    <motion.div
+      className={cn(
+        "h-full px-4 py-4 hidden md:flex md:flex-col bg-neutral-100 dark:bg-neutral-800 w-[300px] flex-shrink-0",
+        className
+      )}
+      animate={{
+        width: animate ? (open ? "300px" : "60px") : "300px",
+      }}
+      onMouseEnter={() => setOpen(true)}
+      onMouseLeave={() => setOpen(false)}
+      {...props}
+    >
+      {children}
+    </motion.div>
+  );
+};
+
+export const MobileSidebar = ({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<"div">) => {
+  const { open, setOpen } = useSidebar();
+  return (
+    <>
+      <div
+        className={cn(
+          "h-10 px-4 py-4 flex flex-row md:hidden items-center justify-between bg-neutral-100 dark:bg-neutral-800 w-full"
+        )}
+        {...props}
+      >
+        <div className="flex justify-end z-20 w-full">
+          <Menu
+            className="text-neutral-800 dark:text-neutral-200 cursor-pointer"
+            onClick={() => setOpen(!open)}
+          />
+        </div>
+        <AnimatePresence>
+          {open && (
+            <motion.div
+              initial={{ x: "-100%", opacity: 0 }}
+              animate={{ x: 0, opacity: 1 }}
+              exit={{ x: "-100%", opacity: 0 }}
+              transition={{
+                duration: 0.3,
+                ease: "easeInOut",
+              }}
+              className={cn(
+                "fixed h-full w-full inset-0 bg-white dark:bg-neutral-900 p-10 z-[100] flex flex-col justify-between",
+                className
+              )}
+            >
+              <div
+                className="absolute right-10 top-10 z-50 text-neutral-800 dark:text-neutral-200 cursor-pointer"
+                onClick={() => setOpen(!open)}
+              >
+                <X />
+              </div>
+              {children}
+            </motion.div>
+          )}
+        </AnimatePresence>
+      </div>
+    </>
+  );
+};
+
+export const SidebarLink = ({
+  link,
+  className,
+  ...props
+}: {
+  link: Links;
+  className?: string;
+  props?: LinkProps;
+}) => {
+  const { open, animate } = useSidebar();
+  return (
+    <Link
+      href={link.href}
+      className={cn(
+        "flex items-center justify-start gap-2 group/sidebar py-2",
+        className
+      )}
+      {...props}
+    >
+      {link.icon}
+      <motion.span
+        animate={{
+          display: animate ? (open ? "inline-block" : "none") : "inline-block",
+          opacity: animate ? (open ? 1 : 0) : 1,
+        }}
+        className="text-neutral-700 dark:text-neutral-200 text-sm group-hover/sidebar:translate-x-1 transition duration-150 whitespace-pre inline-block !p-0 !m-0"
+      >
+        {link.label}
+      </motion.span>
+    </Link>
+  );
+};
diff --git a/croppy_glm.js b/croppy_glm.js
new file mode 100644
index 0000000..5c6d97b
--- /dev/null
+++ b/croppy_glm.js
@@ -0,0 +1,755 @@
+#!/usr/bin/env node
+/**
+ * Croppy GLM — Smart Image Cropper for Glitter Walls GLM-* Products
+ *
+ * Uses Gemini Vision to detect vendor logos, header/footer bars, and
+ * repeating patterns, then intelligently crops images before uploading
+ * back to Shopify.
+ *
+ * Flow per product:
+ *   1. Download image
+ *   2. Gemini Vision analysis (header/footer detection, repeat detection)
+ *   3. Crop header/footer if detected
+ *   4. Square-crop from bottom-up if repeating pattern
+ *   5. Upload cropped image, set position 1, delete old, update alt tag
+ *
+ * Usage: node croppy_glm.js [--dry-run] [--start-at=INDEX]
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+// ── Config ──────────────────────────────────────────────────────────────────
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const API_VER = '2024-10';
+const LOG_FILE = '/tmp/croppy_glm.log';
+const TMP_DIR = '/tmp/croppy_glm_tmp';
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+const GEMINI_MODEL = 'gemini-2.0-flash';
+const GEMINI_HOST = 'generativelanguage.googleapis.com';
+const DRY_RUN = process.argv.includes('--dry-run');
+const START_AT = (() => {
+  const arg = process.argv.find(a => a.startsWith('--start-at='));
+  return arg ? parseInt(arg.split('=')[1], 10) : 0;
+})();
+
+// Ensure tmp dir exists
+if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
+
+// ── Logging ─────────────────────────────────────────────────────────────────
+function log(msg) {
+  const line = `[${new Date().toISOString()}] ${msg}`;
+  console.log(line);
+  fs.appendFileSync(LOG_FILE, line + '\n');
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// ── Shopify GraphQL (native https) ──────────────────────────────────────────
+function gql(query, variables) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({
+      hostname: STORE,
+      path: `/admin/api/${API_VER}/graphql.json`,
+      method: 'POST',
+      headers: {
+        'X-Shopify-Access-Token': TOKEN,
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(body),
+      },
+    }, (res) => {
+      let data = '';
+      res.on('data', (chunk) => data += chunk);
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(data));
+        } catch (e) {
+          reject(new Error(`JSON parse error: ${data.slice(0, 300)}`));
+        }
+      });
+    });
+    req.on('error', reject);
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error('GraphQL request timeout')); });
+    req.write(body);
+    req.end();
+  });
+}
+
+// ── Shopify REST API (native https) ─────────────────────────────────────────
+function shopifyRest(method, endpoint, body) {
+  return new Promise((resolve, reject) => {
+    const payload = body ? JSON.stringify(body) : null;
+    const headers = {
+      'X-Shopify-Access-Token': TOKEN,
+      'Content-Type': 'application/json',
+    };
+    if (payload) headers['Content-Length'] = Buffer.byteLength(payload);
+
+    const req = https.request({
+      hostname: STORE,
+      path: `/admin/api/${API_VER}${endpoint}`,
+      method,
+      headers,
+    }, (res) => {
+      let data = '';
+      res.on('data', (chunk) => data += chunk);
+      res.on('end', () => {
+        try {
+          resolve({ status: res.statusCode, headers: res.headers, data: data ? JSON.parse(data) : {} });
+        } catch (e) {
+          resolve({ status: res.statusCode, headers: res.headers, data: data });
+        }
+      });
+    });
+    req.on('error', reject);
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error('REST request timeout')); });
+    if (payload) req.write(payload);
+    req.end();
+  });
+}
+
+// ── Download file following redirects ───────────────────────────────────────
+function download(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close();
+        try { fs.unlinkSync(dest); } catch (_) {}
+        return download(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      if (res.statusCode !== 200) {
+        file.close();
+        return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
+      }
+      res.pipe(file);
+      file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', (e) => { file.close(); reject(e); });
+  });
+}
+
+// ── ImageMagick helpers ─────────────────────────────────────────────────────
+function getImageDimensions(filePath) {
+  // Use [0] to get ONLY the first frame dimensions (important for animated GIFs
+  // which otherwise report stacked height of all frames combined)
+  const output = execFileSync('identify', ['-format', '%wx%h', `${filePath}[0]`], {
+    timeout: 10000,
+    encoding: 'utf8',
+  }).trim();
+  const firstFrame = output.split('\n')[0].split(/\s/)[0];
+  const [w, h] = firstFrame.split('x').map(Number);
+  return { width: w, height: h };
+}
+
+function isAnimatedGif(filePath) {
+  try {
+    const output = execFileSync('identify', [filePath], {
+      timeout: 10000,
+      encoding: 'utf8',
+    }).trim();
+    // Multiple lines = multiple frames = animated GIF
+    return output.split('\n').length > 1;
+  } catch (_) {
+    return false;
+  }
+}
+
+function cropImage(inputPath, outputPath, width, height, xOffset, yOffset) {
+  // For animated GIFs, extract only the first frame first, then crop
+  // This avoids ImageMagick trying to process all frames
+  const isGif = inputPath.toLowerCase().endsWith('.gif') || isAnimatedGif(inputPath);
+
+  if (isGif) {
+    // Extract first frame only, convert to JPG, then crop
+    const firstFrameFile = outputPath.replace(/\.[^.]+$/, '_frame0.jpg');
+    execFileSync('convert', [
+      `${inputPath}[0]`,
+      '-quality', '93',
+      firstFrameFile,
+    ], { timeout: 30000 });
+
+    // Now crop the single frame
+    execFileSync('convert', [
+      firstFrameFile,
+      '-crop', `${width}x${height}+${xOffset}+${yOffset}`,
+      '+repage',
+      '-quality', '93',
+      outputPath,
+    ], { timeout: 30000 });
+
+    // Cleanup intermediate file
+    try { fs.unlinkSync(firstFrameFile); } catch (_) {}
+  } else {
+    execFileSync('convert', [
+      inputPath,
+      '-crop', `${width}x${height}+${xOffset}+${yOffset}`,
+      '+repage',
+      '-quality', '93',
+      outputPath,
+    ], { timeout: 60000 });
+  }
+}
+
+// ── Gemini Vision API (native https — avoids ENOBUFS) ───────────────────────
+function geminiAnalyze(imageFilePath) {
+  return new Promise((resolve, reject) => {
+    // Read image and convert to base64
+    const imageBuffer = fs.readFileSync(imageFilePath);
+    const base64Image = imageBuffer.toString('base64');
+
+    // Determine MIME type
+    const ext = path.extname(imageFilePath).toLowerCase();
+    let mimeType = 'image/jpeg';
+    if (ext === '.png') mimeType = 'image/png';
+    else if (ext === '.webp') mimeType = 'image/webp';
+
+    const prompt = `Analyze this wallcovering product image. Answer these 3 questions in JSON only:
+1. Does it have a vendor logo, header text, footer text, or info bar at the top or bottom? (yes/no, and which: top/bottom/both)
+2. What percentage of the image height is the header/footer that should be cropped? (0 if none)
+3. Does this wallcovering have a clearly repeating pattern? (yes/no)
+Return ONLY JSON: {"hasHeader": true/false, "headerPosition": "top"/"bottom"/"both"/"none", "cropPercent": 0-20, "hasRepeat": true/false}`;
+
+    const requestBody = JSON.stringify({
+      contents: [{
+        parts: [
+          {
+            inlineData: {
+              mimeType,
+              data: base64Image,
+            },
+          },
+          {
+            text: prompt,
+          },
+        ],
+      }],
+      generationConfig: {
+        temperature: 0.1,
+        maxOutputTokens: 256,
+      },
+    });
+
+    const req = https.request({
+      hostname: GEMINI_HOST,
+      path: `/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(requestBody),
+      },
+    }, (res) => {
+      let data = '';
+      res.on('data', (chunk) => data += chunk);
+      res.on('end', () => {
+        try {
+          const parsed = JSON.parse(data);
+          if (parsed.error) {
+            return reject(new Error(`Gemini API error: ${parsed.error.message || JSON.stringify(parsed.error)}`));
+          }
+          const textContent = parsed?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+          // Extract JSON from response (may be wrapped in ```json ... ```)
+          const jsonMatch = textContent.match(/\{[\s\S]*?\}/);
+          if (!jsonMatch) {
+            return reject(new Error(`No JSON in Gemini response: ${textContent.slice(0, 200)}`));
+          }
+          const result = JSON.parse(jsonMatch[0]);
+          resolve(result);
+        } catch (e) {
+          reject(new Error(`Gemini parse error: ${e.message} | raw: ${data.slice(0, 300)}`));
+        }
+      });
+    });
+
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('Gemini request timeout')); });
+    req.write(requestBody);
+    req.end();
+  });
+}
+
+// ── Shopify: Staged Upload ──────────────────────────────────────────────────
+async function stagedUpload(filePath, filename) {
+  const fileSize = fs.statSync(filePath).size;
+  const mimeType = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';
+
+  const mutation = `mutation {
+    stagedUploadsCreate(input: [{
+      resource: IMAGE,
+      filename: "${filename}",
+      mimeType: "${mimeType}",
+      httpMethod: POST,
+      fileSize: "${fileSize}"
+    }]) {
+      stagedTargets {
+        url
+        parameters { name value }
+        resourceUrl
+      }
+      userErrors { message }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const target = res?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+  if (!target) {
+    throw new Error(`Staged upload failed: ${JSON.stringify(res?.data?.stagedUploadsCreate?.userErrors || res?.errors)}`);
+  }
+
+  // Upload via curl (multipart form upload — curl is fine for single large uploads)
+  const curlArgs = ['-s', '-X', 'POST', target.url];
+  for (const param of target.parameters) {
+    curlArgs.push('-F', `${param.name}=${param.value}`);
+  }
+  curlArgs.push('-F', `file=@${filePath}`);
+  execFileSync('curl', curlArgs, { timeout: 120000 });
+
+  return target.resourceUrl;
+}
+
+// ── Shopify: Create media on product ────────────────────────────────────────
+async function createMedia(productGid, resourceUrl, altText) {
+  const safeAlt = altText.replace(/"/g, '\\"').replace(/\n/g, ' ');
+  const mutation = `mutation {
+    productCreateMedia(productId: "${productGid}", media: [{
+      originalSource: "${resourceUrl}",
+      mediaContentType: IMAGE,
+      alt: "${safeAlt}"
+    }]) {
+      media {
+        id
+      }
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const errors = res?.data?.productCreateMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Create media error: ${errors.map(e => e.message).join(', ')}`);
+  }
+  const media = res?.data?.productCreateMedia?.media;
+  if (!media || media.length === 0) {
+    throw new Error(`No media returned: ${JSON.stringify(res)}`);
+  }
+  return media[0].id;
+}
+
+// ── Shopify: Wait for media READY ───────────────────────────────────────────
+async function waitForMediaReady(productGid, mediaGid, maxWait = 45000) {
+  const start = Date.now();
+  while (Date.now() - start < maxWait) {
+    const query = `{
+      product(id: "${productGid}") {
+        media(first: 20) {
+          edges {
+            node {
+              ... on MediaImage {
+                id
+                status
+              }
+            }
+          }
+        }
+      }
+    }`;
+    const res = await gql(query);
+    const edges = res?.data?.product?.media?.edges || [];
+    for (const edge of edges) {
+      if (edge.node.id === mediaGid) {
+        if (edge.node.status === 'READY') return true;
+        if (edge.node.status === 'FAILED') return false;
+      }
+    }
+    await sleep(2000);
+  }
+  return false;
+}
+
+// ── Shopify: Reorder media ─────────────────────────────────────────────────
+async function reorderMedia(productGid, allMediaIds) {
+  const moves = allMediaIds.map((id, idx) => `{id: "${id}", newPosition: "${idx}"}`);
+  const mutation = `mutation {
+    productReorderMedia(id: "${productGid}", moves: [${moves.join(',')}]) {
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const errors = res?.data?.productReorderMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Reorder error: ${errors.map(e => e.message).join(', ')}`);
+  }
+}
+
+// ── Shopify: Delete media ──────────────────────────────────────────────────
+async function deleteMedia(productGid, mediaIds) {
+  const idsStr = mediaIds.map(id => `"${id}"`).join(',');
+  const mutation = `mutation {
+    productDeleteMedia(productId: "${productGid}", mediaIds: [${idsStr}]) {
+      deletedMediaIds
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const errors = res?.data?.productDeleteMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Delete media error: ${errors.map(e => e.message).join(', ')}`);
+  }
+  return res?.data?.productDeleteMedia?.deletedMediaIds || [];
+}
+
+// ── Shopify: Update product alt text for image via REST ─────────────────────
+async function updateImageAlt(productId, imageId, altText) {
+  const numericProductId = String(productId).replace(/\D/g, '') || productId;
+  const numericImageId = String(imageId).replace(/\D/g, '') || imageId;
+  const result = await shopifyRest('PUT', `/products/${numericProductId}/images/${numericImageId}.json`, {
+    image: { id: parseInt(numericImageId), alt: altText },
+  });
+  return result;
+}
+
+// ── Fetch ALL Glitter Walls products via GraphQL pagination ─────────────────
+async function fetchAllProducts() {
+  const products = [];
+  let cursor = null;
+  let page = 0;
+
+  while (true) {
+    page++;
+    const afterClause = cursor ? `, after: "${cursor}"` : '';
+    const query = `{
+      products(first: 50, query: "vendor:'Glitter Walls'"${afterClause}) {
+        edges {
+          cursor
+          node {
+            id
+            title
+            variants(first: 5) {
+              edges {
+                node {
+                  sku
+                }
+              }
+            }
+            media(first: 10) {
+              edges {
+                node {
+                  ... on MediaImage {
+                    id
+                    image {
+                      url
+                      width
+                      height
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+        pageInfo {
+          hasNextPage
+        }
+      }
+    }`;
+
+    const res = await gql(query);
+    if (res.errors) {
+      log(`GraphQL error on page ${page}: ${JSON.stringify(res.errors)}`);
+      break;
+    }
+
+    const edges = res?.data?.products?.edges || [];
+    for (const edge of edges) {
+      products.push(edge.node);
+      cursor = edge.cursor;
+    }
+
+    log(`Fetched page ${page}: ${edges.length} products (total: ${products.length})`);
+
+    if (!res?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(400);
+  }
+
+  return products;
+}
+
+// ── Determine crop parameters from Gemini analysis ──────────────────────────
+function determineCrop(geminiResult, imgWidth, imgHeight) {
+  const { hasHeader, headerPosition, cropPercent, hasRepeat } = geminiResult;
+  const isSquare = Math.abs(imgWidth - imgHeight) <= (imgWidth * 0.05); // within 5%
+
+  // Case 5: Already square and no header → SKIP
+  if (isSquare && !hasHeader) {
+    return { action: 'skip', reason: 'already square, no header' };
+  }
+
+  let cropTop = 0;
+  let cropBottom = 0;
+
+  // Case 3: Has header/footer → crop it off
+  if (hasHeader && cropPercent > 0) {
+    const cropPixels = Math.round(imgHeight * (cropPercent / 100));
+
+    if (headerPosition === 'top') {
+      cropTop = cropPixels;
+    } else if (headerPosition === 'bottom') {
+      cropBottom = cropPixels;
+    } else if (headerPosition === 'both') {
+      // Split evenly between top and bottom
+      cropTop = Math.round(cropPixels / 2);
+      cropBottom = Math.round(cropPixels / 2);
+    }
+  }
+
+  // After header/footer removal, calculate remaining dimensions
+  const remainingHeight = imgHeight - cropTop - cropBottom;
+  const yOffset = cropTop;
+
+  // Now determine if we need to square-crop
+  if (remainingHeight <= imgWidth) {
+    // Already shorter than or equal to width after header crop
+    if (cropTop === 0 && cropBottom === 0) {
+      return { action: 'skip', reason: 'wider than tall, no header to crop' };
+    }
+    return {
+      action: 'crop',
+      reason: `crop header/footer (${headerPosition}, ${cropPercent}%)`,
+      width: imgWidth,
+      height: remainingHeight,
+      xOffset: 0,
+      yOffset,
+    };
+  }
+
+  // Image is still taller than wide after header removal
+  if (hasHeader || hasRepeat) {
+    // Crop from bottom-up to make square (width x width)
+    const finalHeight = imgWidth;
+    // yOffset stays at cropTop (keep top content, cut from bottom)
+    return {
+      action: 'crop',
+      reason: hasHeader
+        ? `crop header/footer (${headerPosition}, ${cropPercent}%) + square from bottom`
+        : 'repeating pattern, square from bottom',
+      width: imgWidth,
+      height: finalHeight,
+      xOffset: 0,
+      yOffset,
+    };
+  }
+
+  // Case 4b: No header, no repeat → leave as-is
+  return { action: 'skip', reason: 'no header, no repeat, leave as-is' };
+}
+
+// ── Main ────────────────────────────────────────────────────────────────────
+async function main() {
+  log('');
+  log('='.repeat(70));
+  log('  CROPPY GLM — Smart Image Cropper for Glitter Walls');
+  log(`  Mode: ${DRY_RUN ? 'DRY RUN (no changes)' : 'LIVE'}${START_AT > 0 ? ` | Starting at index ${START_AT}` : ''}`);
+  log('='.repeat(70));
+  log('');
+
+  // 1. Fetch all Glitter Walls products
+  log('Fetching all Glitter Walls products from Shopify...');
+  const allProducts = await fetchAllProducts();
+  log(`Total Glitter Walls products: ${allProducts.length}`);
+
+  // 2. Filter to ALL GLM-* SKU products (including SAMPLE variants)
+  const glmProducts = allProducts.filter(p => {
+    const sku = p.variants?.edges?.[0]?.node?.sku || '';
+    return sku.startsWith('GLM-');
+  });
+  log(`GLM-* products (all): ${glmProducts.length}`);
+
+  if (glmProducts.length === 0) {
+    log('FATAL: No GLM-* products found. Exiting.');
+    return;
+  }
+
+  // Stats
+  let cropped = 0, skipped = 0, skippedNoImage = 0, errors = 0, geminiErrors = 0;
+  const startIdx = Math.max(0, START_AT);
+
+  for (let i = startIdx; i < glmProducts.length; i++) {
+    const product = glmProducts[i];
+    const sku = product.variants?.edges?.[0]?.node?.sku || 'NO_SKU';
+    const shortTitle = product.title.slice(0, 60);
+    const tag = `[${i + 1}/${glmProducts.length}]`;
+
+    // Get first MediaImage
+    const mediaEdges = product.media?.edges || [];
+    const firstImage = mediaEdges.find(e => e.node.image);
+    if (!firstImage) {
+      log(`${tag} ${sku} — NO IMAGE, skip`);
+      skippedNoImage++;
+      continue;
+    }
+
+    const imgNode = firstImage.node;
+    const imgUrl = imgNode.image.url;
+    const origW = imgNode.image.width;
+    const origH = imgNode.image.height;
+    const oldMediaId = imgNode.id;
+
+    // Detect file extension from URL
+    const urlExt = imgUrl.match(/\.(gif|png|webp|jpg|jpeg)/i)?.[1]?.toLowerCase() || 'jpg';
+    const inputExt = urlExt === 'jpeg' ? 'jpg' : urlExt;
+    const inputFile = path.join(TMP_DIR, `input_${i}.${inputExt}`);
+    const croppedFile = path.join(TMP_DIR, `cropped_${i}.jpg`); // Always output as JPG
+
+    try {
+      // ── Step 1: Download image ──
+      await download(imgUrl, inputFile);
+      const dims = getImageDimensions(inputFile);
+
+      // ── Step 2: Gemini Vision analysis ──
+      let geminiResult;
+      try {
+        geminiResult = await geminiAnalyze(inputFile);
+        log(`${tag} ${sku} — Gemini: header=${geminiResult.hasHeader}, pos=${geminiResult.headerPosition}, crop=${geminiResult.cropPercent}%, repeat=${geminiResult.hasRepeat}`);
+      } catch (gemErr) {
+        log(`${tag} ${sku} — Gemini FAILED: ${gemErr.message}. Falling back to simple analysis.`);
+        geminiErrors++;
+        // Fallback: no header, check if likely repeating based on aspect ratio
+        const ratio = dims.width / dims.height;
+        geminiResult = {
+          hasHeader: false,
+          headerPosition: 'none',
+          cropPercent: 0,
+          hasRepeat: ratio < 0.85, // tall images likely have repeating patterns
+        };
+      }
+
+      // ── Step 3: Determine crop action ──
+      const cropAction = determineCrop(geminiResult, dims.width, dims.height);
+
+      if (cropAction.action === 'skip') {
+        log(`${tag} ${sku} — SKIP: ${cropAction.reason} (${dims.width}x${dims.height})`);
+        skipped++;
+        if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
+        await sleep(500);
+        continue;
+      }
+
+      // ── Step 4: Crop the image ──
+      log(`${tag} ${sku} — CROP: ${cropAction.reason} | ${dims.width}x${dims.height} → ${cropAction.width}x${cropAction.height} (offset +${cropAction.xOffset}+${cropAction.yOffset})`);
+
+      if (DRY_RUN) {
+        log(`${tag} ${sku} — DRY RUN: would crop and upload`);
+        skipped++;
+        if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
+        await sleep(500);
+        continue;
+      }
+
+      cropImage(inputFile, croppedFile, cropAction.width, cropAction.height, cropAction.xOffset, cropAction.yOffset);
+
+      // Verify cropped dimensions
+      const croppedDims = getImageDimensions(croppedFile);
+      log(`${tag} ${sku} — Cropped file: ${croppedDims.width}x${croppedDims.height}`);
+
+      // ── Step 5: Staged upload ──
+      const ext = imgUrl.includes('.png') ? '.png' : '.jpg';
+      const filename = `croppy_${sku.replace(/[^a-zA-Z0-9-]/g, '_')}${ext}`;
+      const resourceUrl = await stagedUpload(croppedFile, filename);
+      await sleep(500);
+
+      // ── Step 6: Create media with updated alt text ──
+      const titlePart = product.title.split('|')[0].trim();
+      const altText = `${sku} ${titlePart} designer-wallcoverings-los-angeles`;
+      const newMediaId = await createMedia(product.id, resourceUrl, altText);
+      log(`${tag} ${sku} — New media created: ${newMediaId}`);
+
+      // ── Step 7: Wait for media to be READY ──
+      const ready = await waitForMediaReady(product.id, newMediaId, 45000);
+      if (!ready) {
+        log(`${tag} ${sku} — WARNING: Media not READY after 45s, proceeding anyway`);
+      }
+
+      // ── Step 8: Reorder — new image first ──
+      const mediaQuery = `{
+        product(id: "${product.id}") {
+          media(first: 20) {
+            edges {
+              node {
+                ... on MediaImage { id }
+              }
+            }
+          }
+        }
+      }`;
+      const mediaRes = await gql(mediaQuery);
+      const currentMedia = (mediaRes?.data?.product?.media?.edges || [])
+        .map(e => e.node.id)
+        .filter(id => id);
+
+      const reordered = [newMediaId, ...currentMedia.filter(id => id !== newMediaId && id !== oldMediaId)];
+      await reorderMedia(product.id, reordered);
+      log(`${tag} ${sku} — Reordered: new image at position 1`);
+
+      // ── Step 9: Delete old image ──
+      const deleted = await deleteMedia(product.id, [oldMediaId]);
+      log(`${tag} ${sku} — Deleted old image: ${deleted.join(', ')}`);
+
+      cropped++;
+
+      // Cleanup tmp files
+      if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
+      if (fs.existsSync(croppedFile)) fs.unlinkSync(croppedFile);
+
+    } catch (err) {
+      log(`${tag} ${sku} — ERROR: ${err.message}`);
+      errors++;
+      if (fs.existsSync(inputFile)) try { fs.unlinkSync(inputFile); } catch (_) {}
+      if (fs.existsSync(croppedFile)) try { fs.unlinkSync(croppedFile); } catch (_) {}
+    }
+
+    // Rate limit: 500ms between products, 1200ms extra after Gemini calls (already waited in geminiAnalyze)
+    await sleep(500);
+
+    // Extra Gemini cooldown every product (1200ms between Gemini calls)
+    await sleep(1200);
+  }
+
+  // ── Summary ──
+  log('');
+  log('='.repeat(70));
+  log('  CROPPY GLM — SUMMARY');
+  log('='.repeat(70));
+  log(`  Total GLM products:     ${glmProducts.length}`);
+  log(`  Cropped & uploaded:     ${cropped}`);
+  log(`  Skipped (no change):    ${skipped}`);
+  log(`  Skipped (no image):     ${skippedNoImage}`);
+  log(`  Gemini fallbacks:       ${geminiErrors}`);
+  log(`  Errors:                 ${errors}`);
+  log(`  Mode:                   ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
+  log('='.repeat(70));
+}
+
+main().catch((e) => {
+  log(`FATAL: ${e.message}`);
+  console.error(e);
+  process.exit(1);
+});
diff --git a/data/tracking.json b/data/tracking.json
new file mode 100644
index 0000000..00ca52d
--- /dev/null
+++ b/data/tracking.json
@@ -0,0 +1,289 @@
+[
+  {
+    "timestamp": "2026-01-12T19:06:31.981Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: Omega Watches Historical Price Tracking and Visualization System",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T19:09:08.786Z",
+    "project": "watches",
+    "step": "ralph_started",
+    "details": "Started Ralph for: React 19 standalone application for tracking, analyzing, and visualizing historical pricing data for Omega watches with comprehensive insights into price trends and market movements.",
+    "status": "in_progress"
+  },
+  {
+    "timestamp": "2026-01-12T19:22:14.967Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: Omega Watches Historical Price Tracking and Visualization System",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T19:25:35.333Z",
+    "project": "watches",
+    "step": "ralph_started",
+    "details": "Started Ralph for: A web application for tracking and visualizing historical pricing data for Omega timepieces with dark mode support and responsive design",
+    "status": "in_progress"
+  },
+  {
+    "timestamp": "2026-01-12T19:45:15.031Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for watches",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T20:00:17.155Z",
+    "project": "angels-flowers",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for angels-flowers",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T20:20:36.415Z",
+    "project": "wine-finder",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for wine-finder",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T20:44:12.635Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for Designer-Wallcoverings for Recently Viewed Products",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-12T23:57:29.355Z",
+    "project": "angels-flowers",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for angels-flowers",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T00:56:42.207Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for watches",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T01:16:13.885Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for watches",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T05:52:00.398Z",
+    "project": "watches",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for watches",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T07:32:26.840Z",
+    "project": "angels-flowers",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for angels-flowers",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T08:04:05.992Z",
+    "project": "angels-flowers",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for angels-flowers",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-13T08:19:12.260Z",
+    "project": "angels-flowers",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for angels-flowers",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:15:03.513Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: create a room setting for https://www.designerwall...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:21:49.295Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: using room setting skill, create a room setting fo...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:32:51.271Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Using Room Setting Skill, create 5 unique room set...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:35:47.211Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Using Room Setting Skill, create 5 unique room set...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:37:33.003Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Using Room Setting Skill, create 5 unique room set...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T00:51:01.062Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Using Room Setting Skill, create 5 unique room set...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T02:31:26.774Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Using Room Setting generator Skill, create 5 uniqu...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T02:33:12.965Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: AAdd to the claude.md file in Designer Wallcoverin...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T03:16:17.521Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: New Feature for jill-website",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T04:51:38.153Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Focus strictly on UI and make sure all the images,...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T04:55:21.323Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Focus strictly on UI and make sure all the images,...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T05:18:53.060Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Quick Mode Task",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T05:35:02.536Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Quick Mode Task",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T16:51:20.536Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Update all activities with real llinks out to all ...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T17:00:23.406Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Update all activities with real llinks out to all ...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T17:34:55.829Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Kravet Design - W3922-130 Green | Kravet Design | ...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T18:57:02.318Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Kravet Design - W3922-130 Green | Kravet Design | ...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T19:09:14.053Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Kravet Design - W3922-130 Green | Kravet Design | ...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-14T23:31:18.418Z",
+    "project": "jill-website",
+    "step": "prd_generated",
+    "details": "Generated PRD: Use images from front page herop image for our fro...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-15T16:02:25.458Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: Review all Phillipe Romano Vendor products on shop...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-21T01:43:52.092Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: You are controlling a Chrome browser via claude-ch...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-21T02:00:12.205Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: You are Claude Code controlling Chrome via Playwri...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-21T23:33:48.464Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: for all vendor koroseal, innovatioinsusa, change t...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-21T23:40:11.740Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: for vendor koroseal change type to  to Architectur...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-01-22T15:19:45.745Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: using letsbegin project and ralphy boy, we need to...",
+    "status": "success"
+  },
+  {
+    "timestamp": "2026-02-03T23:49:15.928Z",
+    "project": "Designer-Wallcoverings",
+    "step": "prd_generated",
+    "details": "Generated PRD: https://admin.shopify.com/store/designer-laborator...",
+    "status": "success"
+  }
+]
\ No newline at end of file
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..a80e1ec
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,17 @@
+module.exports = {
+  apps: [{
+    name: 'letsbegin',
+    script: 'npm',
+    args: 'start',
+    cwd: '/root/Projects/Letsbegin',
+    env: {
+      NODE_ENV: 'production',
+      PORT: 7300
+    },
+    instances: 1,
+    exec_mode: 'fork',
+    autorestart: true,
+    watch: false,
+    max_memory_restart: '1G'
+  }]
+}
diff --git a/fix_dwpx_images.js b/fix_dwpx_images.js
new file mode 100644
index 0000000..79418fd
--- /dev/null
+++ b/fix_dwpx_images.js
@@ -0,0 +1,114 @@
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+// DW Italian name → Astek English name mapping
+const NAME_MAP = {
+  'spazzolato': 'brushed metal wallcovering',
+  'ondulato': 'corrugated wallcovering',
+  'coccodrillo': 'croc wallcovering',
+  'sbalzato': 'embossed metal wallcovering',
+  'metallico': 'metal wallcovering',
+  'tessuto metallico': 'metallic textile wallcovering',
+  'trapuntato': 'quilted vinyl wallcovering',
+};
+
+function gql(body) {
+  return new Promise((res, rej) => {
+    const data = JSON.stringify(body);
+    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
+    req.on('error', rej); req.write(data); req.end();
+  });
+}
+
+function download(url, dest) {
+  return new Promise((res, rej) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, r => {
+      if (r.statusCode >= 300 && r.headers.location) { file.close(); return download(r.headers.location, dest).then(res).catch(rej); }
+      r.pipe(file); file.on('finish', () => { file.close(); res(); });
+    }).on('error', e => { file.close(); rej(e); });
+  });
+}
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function main() {
+  console.log('=== DWPX Image Fixer (Astek Decadent) ===\n');
+
+  // Load Astek lookup
+  const astek = JSON.parse(fs.readFileSync('/tmp/astek_lookup.json'));
+
+  // Load no-image products
+  const products = JSON.parse(fs.readFileSync('/tmp/no_img_products.json'));
+  console.log(`${products.length} products need images\n`);
+
+  let ok = 0, noMatch = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    // Parse: "Spazzolato - Bronze Wallcovering | Phillipe Romano"
+    const titleParts = p.title.split('|')[0].trim();
+    const dashParts = titleParts.split(' - ');
+    const dwPattern = (dashParts[0] || '').trim().toLowerCase();
+    const colorPart = (dashParts[1] || '').replace(/wallcovering/gi, '').trim().toLowerCase();
+
+    // Map DW name to Astek name
+    const astekPattern = NAME_MAP[dwPattern];
+    if (!astekPattern) { console.log(`  [${i+1}] ${p.title.slice(0,40)} — no pattern map for "${dwPattern}"`); noMatch++; continue; }
+
+    const key = `${astekPattern}|${colorPart}`;
+    const match = astek[key];
+    if (!match || !match.img) { console.log(`  [${i+1}] ${p.title.slice(0,40)} — no Astek match for "${key}"`); noMatch++; continue; }
+
+    // Download image
+    const tmpFile = '/tmp/dwpx_img.jpg';
+    try {
+      await download(match.img, tmpFile);
+      const fileSize = fs.statSync(tmpFile).size;
+
+      // Staged upload
+      const stage = await gql({
+        query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "${match.sku}.jpg", mimeType: "image/jpeg", httpMethod: POST, fileSize: "${fileSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } } }`
+      });
+      const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+      if (!target) { errors++; continue; }
+
+      const curlArgs = ['-s', '-X', 'POST', target.url];
+      for (const param of target.parameters) curlArgs.push('-F', `${param.name}=${param.value}`);
+      curlArgs.push('-F', `file=@${tmpFile}`);
+      execFileSync('curl', curlArgs, { timeout: 30000 });
+
+      // Create media
+      const pid = p.id.split('/').pop();
+      const r = await gql({
+        query: `mutation { productCreateMedia(productId: "gid://shopify/Product/${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${p.title.split('|')[0].trim()}"}]) { media { id } mediaUserErrors { message } } }`
+      });
+
+      if (r?.data?.productCreateMedia?.media?.[0]) {
+        ok++;
+        // Also set manufacturer_sku
+        await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: [{ ownerId: p.id, namespace: 'custom', key: 'manufacturer_sku', value: match.sku.split('__')[0], type: 'single_line_text_field' }] }
+        });
+        console.log(`  [${i+1}] ${p.title.slice(0,40)} → ${match.sku} ✓`);
+      } else { errors++; }
+    } catch (e) { errors++; console.log(`  [${i+1}] ERROR: ${e.message?.slice(0,50)}`); }
+
+    await sleep(800);
+  }
+
+  console.log(`\n=== DONE === Images: ${ok} | No match: ${noMatch} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/fix_rew_products.js b/fix_rew_products.js
new file mode 100644
index 0000000..75d3465
--- /dev/null
+++ b/fix_rew_products.js
@@ -0,0 +1,218 @@
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+
+if (!TOKEN || !GEMINI_KEY) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN and GEMINI_API_KEY environment variables are required');
+}
+
+function gql(body) {
+  return new Promise((res, rej) => {
+    const data = JSON.stringify(body);
+    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
+    req.on('error', rej); req.setTimeout(30000, ()=>{req.destroy();rej(new Error('timeout'))}); req.write(data); req.end();
+  });
+}
+function rest(method, path, body) {
+  return new Promise((res, rej) => {
+    const data = body ? JSON.stringify(body) : null;
+    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10' + path, method,
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data && { 'Content-Length': Buffer.byteLength(data) }) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(c)} }); });
+    req.on('error', rej); if(data) req.write(data); req.end();
+  });
+}
+function download(url, dest) {
+  return new Promise((res, rej) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, {headers:{'User-Agent':'Mozilla/5.0'}}, r => {
+      if(r.statusCode>=300&&r.headers.location){file.close();return download(r.headers.location,dest).then(res).catch(rej)}
+      r.pipe(file); file.on('finish',()=>{file.close();res()});
+    }).on('error',e=>{file.close();rej(e)});
+  });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function main() {
+  console.log('=== REW- Product Fixer: Title + Price + Image Crop ===\n');
+
+  let cursor = null;
+  const products = [];
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({ query: `{ products(first: 50, query: "sku:REW-* AND status:active"${after}) { edges { cursor node { id title vendor images(first:1) { edges { node { id url width height } } } variants(first:3) { edges { node { id sku title price } } } } } pageInfo { hasNextPage } } }` });
+    for (const e of r.data.products.edges) { products.push(e.node); cursor = e.cursor; }
+    if (!r.data.products.pageInfo.hasNextPage) break;
+    await sleep(500);
+  }
+  console.log(`${products.length} REW products\n`);
+
+  let titles = 0, prices = 0, crops = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const title = p.title;
+
+    // 1. FIX TITLE: add "Wallcovering | Hollywood Wallcoverings" if missing
+    let newTitle = title;
+    if (!title.includes('| Hollywood')) {
+      // Strip existing vendor suffix if any
+      const base = title.split('|')[0].trim();
+      // Add Wallcovering if not already there
+      const withWC = base.includes('Wallcovering') ? base : base + ' Wallcovering';
+      newTitle = `${withWC} | Hollywood Wallcoverings`;
+    }
+
+    if (newTitle !== title) {
+      await gql({
+        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+        variables: { i: { id: p.id, title: newTitle } }
+      });
+      titles++;
+    }
+
+    // 2. FIX PRICE: yard variants to $66.95
+    for (const v of p.variants.edges) {
+      const variant = v.node;
+      if (!variant.sku.includes('sample') && variant.price !== '66.95') {
+        const vid = variant.id.split('/').pop();
+        await rest('PUT', `/variants/${vid}.json`, { variant: { id: parseInt(vid), price: '66.95' } });
+        prices++;
+        await sleep(500);
+      }
+    }
+
+    // 3. CROP IMAGE: remove bottom logo footer (~15% of height)
+    const img = p.images?.edges?.[0]?.node;
+    if (img && img.height > 500 && img.width < img.height) {
+      // Image is portrait with logo at bottom — crop bottom ~15%
+      const cropHeight = Math.round(img.height * 0.85); // keep top 85%
+
+      try {
+        const tmpOrig = '/tmp/rew_orig.jpg';
+        const tmpCrop = '/tmp/rew_crop.jpg';
+        await download(img.url, tmpOrig);
+
+        // Verify it has a logo footer (bottom section is different)
+        execFileSync('convert', [tmpOrig, '-crop', `${img.width}x${cropHeight}+0+0`, '+repage', '-quality', '93', tmpCrop]);
+
+        const cropSize = fs.statSync(tmpCrop).size;
+
+        // Upload cropped version
+        const stage = await gql({
+          query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "cropped.jpg", mimeType: "image/jpeg", httpMethod: POST, fileSize: "${cropSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } } }`
+        });
+        const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+        if (target) {
+          const curlArgs = ['-s', '-X', 'POST', target.url];
+          for (const pm of target.parameters) curlArgs.push('-F', `${pm.name}=${pm.value}`);
+          curlArgs.push('-F', `file=@${tmpCrop}`);
+          execFileSync('curl', curlArgs, { timeout: 30000 });
+
+          // Add cropped image
+          const pid = p.id.split('/').pop();
+          await gql({
+            query: `mutation { productCreateMedia(productId: "gid://shopify/Product/${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${newTitle.split('|')[0].trim()}"}]) { media { id } mediaUserErrors { message } } }`
+          });
+
+          // Delete old image
+          await gql({
+            query: `mutation { productDeleteMedia(productId: "gid://shopify/Product/${pid}", mediaIds: ["${img.id}"]) { deletedMediaIds mediaUserErrors { message } } }`
+          });
+
+          crops++;
+        }
+      } catch (e) { errors++; }
+      await sleep(800);
+    }
+
+    // 4. AI COLOR ANALYSIS + SPECS (full monty minus room settings)
+    if (img) {
+      try {
+        const tmpImg = fs.existsSync('/tmp/rew_crop.jpg') ? '/tmp/rew_crop.jpg' : '/tmp/rew_orig.jpg';
+        const b64 = fs.readFileSync(tmpImg).toString('base64');
+
+        // Gemini color analysis
+        const cr = await new Promise((res) => {
+          const data = JSON.stringify({
+            contents: [{ parts: [
+              { text: 'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. 3-6 colors summing to 100%. Title Case.' },
+              { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+            ]}],
+            generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+          });
+          const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+          const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+          }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
+          req.on('error', ()=>res(null)); req.write(data); req.end();
+        });
+
+        const text = (cr?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/```json?\s*/g,'').replace(/```/g,'').trim();
+        const cd = JSON.parse(text);
+
+        // Set color + spec metafields
+        await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: [
+            { ownerId: p.id, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
+            { ownerId: p.id, namespace: 'custom', key: 'color_name', value: cd.dominantColor, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'width', value: '54 Inches', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'material', value: 'Type II Vinyl Wallcovering', type: 'multi_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'fire_rating', value: 'ASTM E-84 Class A', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'brand', value: 'Hollywood Wallcoverings', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'unit_of_measure', value: 'Sold Per Yard', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'packaging', value: 'Full Roll', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'cost', value: '36.99', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'designer_net', value: '56.90', type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'dw_price', value: '66.95', type: 'single_line_text_field' },
+          ]}
+        });
+
+        // AI body description
+        const dr = await new Promise((res) => {
+          const data = JSON.stringify({
+            contents: [{ parts: [
+              { text: `Describe this vinyl wallcovering called "${newTitle.split('|')[0].trim()}" in one sentence for a design-trade buyer. Mention the texture and recommended use. Do NOT say "wallpaper".` },
+              { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+            ]}],
+            generationConfig: { temperature: 0.3, maxOutputTokens: 100 }
+          });
+          const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+          const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+          }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
+          req.on('error', ()=>res(null)); req.write(data); req.end();
+        });
+
+        const aiDesc = (dr?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
+        const baseName = newTitle.split('|')[0].trim();
+        const body = `<p>${baseName} is a premium vinyl wallcovering by Hollywood Wallcoverings. This commercial-grade design delivers sophisticated texture and durability for high-traffic environments.</p>\n<p>${aiDesc || 'Ideal for hospitality, commercial, and upscale residential interiors.'}</p>`;
+
+        await gql({
+          query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+          variables: { i: { id: p.id, bodyHtml: body } }
+        });
+
+        console.log(`  Color: ${cd.dominantColor} | Specs+Body set`);
+      } catch (e) { errors++; }
+      await sleep(1500);
+    }
+
+    if ((i+1) % 10 === 0) console.log(`[${i+1}/${products.length}] titles:${titles} prices:${prices} crops:${crops} errors:${errors}`);
+  }
+
+  console.log(`\n=== COMPLETE ===`);
+  console.log(`Titles fixed: ${titles} | Prices fixed: ${prices} | Images cropped: ${crops} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/fix_x_mfr.js b/fix_x_mfr.js
new file mode 100644
index 0000000..a7449ff
--- /dev/null
+++ b/fix_x_mfr.js
@@ -0,0 +1,74 @@
+const https = require('https');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function main() {
+  console.log('=== X-Series MFR SKU Fixer v2 ===');
+  console.log('global.manufacturer_sku → custom.manufacturer_sku\n');
+  let cursor = null, page = 1, fixed = 0, noGlobal = 0, already = 0, total = 0, errors = 0;
+
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({
+      query: `{ products(first: 25, query: "sku:X* AND status:active"${after}) {
+        edges { cursor node { id title metafields(first: 30) { edges { node { namespace key value } } } } }
+        pageInfo { hasNextPage }
+      } }`
+    });
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+
+    for (const edge of edges) {
+      const p = edge.node;
+      cursor = edge.cursor;
+      total++;
+
+      let globalMfr = '', globalVni = '', customMfr = '';
+      for (const m of p.metafields.edges) {
+        const ns = m.node.namespace, k = m.node.key, v = m.node.value || '';
+        if (ns === 'global' && k === 'manufacturer_sku') globalMfr = v;
+        if (ns === 'global' && k === 'vendor_name_internal') globalVni = v;
+        if (ns === 'custom' && k === 'manufacturer_sku') customMfr = v;
+      }
+
+      const realMfr = globalMfr || globalVni;
+      if (!realMfr) { noGlobal++; continue; }
+      if (customMfr === realMfr) { already++; continue; }
+      if (customMfr && /[A-Za-z]/.test(customMfr) && customMfr.length > 5) { already++; continue; }
+
+      try {
+        const result = await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: [{ ownerId: p.id, namespace: 'custom', key: 'manufacturer_sku', value: realMfr, type: 'single_line_text_field' }] }
+        });
+        if ((result?.data?.metafieldsSet?.userErrors || []).length === 0) fixed++;
+        else errors++;
+      } catch { errors++; }
+      await sleep(400);
+    }
+
+    console.log(`Page ${page}: total=${total} fixed=${fixed} noGlobal=${noGlobal} already=${already}`);
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    page++; await sleep(200);
+  }
+
+  console.log(`\n=== DONE === Fixed: ${fixed} | No global MFR: ${noGlobal} | Already correct: ${already} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/glitter_full_monty.js b/glitter_full_monty.js
new file mode 100644
index 0000000..b00d6d0
--- /dev/null
+++ b/glitter_full_monty.js
@@ -0,0 +1,618 @@
+#!/usr/bin/env node
+/**
+ * Glitter Walls Full Monty (NO room settings)
+ * ~410 products, vendor "Glitter Walls"
+ *
+ * Pipeline:
+ *   1. Migrate global.* → custom.* metafields (width, fire_rating, material, repeat, match_type, etc.)
+ *   2. AI color analysis via Gemini (dominant color name, hex, color_details JSON)
+ *   3. Body rewrite to exactly 2 paragraphs (editorial + AI description)
+ *   4. Title update: add color if missing, format "Pattern - Color | Glitter Walls"
+ *   5. Set custom.full_monty_date = "2026-04-06"
+ *   6. Update alt tags on images: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
+ *   7. NO room settings generation
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+const FULL_MONTY_DATE = '2026-04-06';
+const LOG_FILE = '/tmp/glitter_monty.log';
+
+// Initialize log
+fs.writeFileSync(LOG_FILE, `=== Glitter Walls Full Monty ===\nStarted: ${new Date().toISOString()}\n\n`);
+
+function log(msg) {
+  const line = `[${new Date().toISOString()}] ${msg}`;
+  console.log(line);
+  fs.appendFileSync(LOG_FILE, line + '\n');
+}
+
+function gqlRaw(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+
+async function gql(body, retries = 3) {
+  for (let attempt = 1; attempt <= retries; attempt++) {
+    try {
+      const result = await gqlRaw(body);
+      // Check for throttling
+      if (result?.errors?.[0]?.message?.includes('Throttled')) {
+        log(`  GQL throttled, waiting 3s (attempt ${attempt}/${retries})`);
+        await sleep(3000);
+        continue;
+      }
+      return result;
+    } catch (e) {
+      if (attempt < retries) {
+        log(`  GQL error: ${e.message}, retrying in ${attempt * 2}s (attempt ${attempt}/${retries})`);
+        await sleep(attempt * 2000);
+        continue;
+      }
+      throw e;
+    }
+  }
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function downloadImage(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close(); return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      if (res.statusCode !== 200) {
+        file.close(); return reject(new Error(`HTTP ${res.statusCode}`));
+      }
+      res.pipe(file); file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', e => { file.close(); reject(e); });
+  });
+}
+
+function geminiCall(reqBody) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({
+      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, res => {
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); } });
+    });
+    req.on('error', () => resolve(null));
+    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
+    req.write(data); req.end();
+  });
+}
+
+// ---- Cleaners for global → custom migration ----
+
+function cleanWidth(v) {
+  if (!v) return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+function cleanRepeat(v) {
+  if (!v || v === 'N/A' || v === '0' || v.toLowerCase() === 'random' || v.toLowerCase() === 'none') return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+function cleanFireRating(v) {
+  if (!v) return null;
+  let f = v.replace(/["']/g, '').trim();
+  if (f.length > 100) return null;
+  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
+  return f || null;
+}
+
+function cleanMaterial(v) {
+  if (!v) return null;
+  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
+}
+
+// Global → Custom field mapping
+const FIELD_MAP = {
+  'width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'Width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Rpt': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'fire_rating': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'FLAMMABILITY': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'Contents': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Content': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Construction': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Collection': { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Brand': { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'MATCH': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Match': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Finish': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'FINISH': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Cleaning': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Clean-Code': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'application': { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'unit_of_measure': { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Weight': { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country-of-Origin': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'color': { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Color-Way': { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+};
+
+// ---- Title parsing helpers ----
+
+function extractPatternAndColor(title) {
+  // Try to extract pattern name and color from title
+  // Formats seen:
+  //   "Venezia Glass Beads Wallcovering - Gold"
+  //   "Venezia Glass Beads - Red Rock"
+  //   "Venezia(tm) Glass Beads - Silver"
+  //   "Sequin - Eel Skin White Gold Wallcovering"
+  //   "Pattern - Color | Glitter Walls" (already processed)
+
+  // First, strip "| Glitter Walls" suffix if present (already processed product)
+  let cleaned = title.replace(/\s*\|\s*Glitter\s*Walls\s*$/i, '').trim();
+  // Remove (tm), wallcovering(s), wallpaper(s)
+  cleaned = cleaned.replace(/\(tm\)/gi, '').replace(/\bwallcoverings?\b/gi, '').replace(/\bwall\s*coverings?\b/gi, '').replace(/\bwall\s*papers?\b/gi, '').replace(/\s+/g, ' ').trim();
+
+  // Try dash split — use the LAST dash to handle patterns like "Sequin - Eel Skin - White Gold"
+  const lastDash = cleaned.lastIndexOf(' - ');
+  if (lastDash > 0) {
+    // But handle case where there's a dash in the middle like "Sexy Sequin- Miami Beach"
+    const dashMatch = cleaned.match(/^(.+?)\s*[-–]\s*(.+)$/);
+    if (dashMatch) {
+      return { pattern: dashMatch[1].trim(), color: dashMatch[2].trim() };
+    }
+  }
+
+  // Try pipe
+  const pipeMatch = cleaned.match(/^(.+?)\s*\|\s*(.+)$/);
+  if (pipeMatch) {
+    return { pattern: pipeMatch[1].trim(), color: null };
+  }
+  return { pattern: cleaned.trim(), color: null };
+}
+
+function toTitleCase(s) {
+  if (!s) return '';
+  return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
+}
+
+function buildTitle(pattern, color) {
+  // Format: "Pattern - Color | Glitter Walls"
+  // Ensure color is Title Case
+  const tc = toTitleCase(color);
+  if (tc) {
+    return `${pattern} - ${tc} | Glitter Walls`;
+  }
+  return `${pattern} | Glitter Walls`;
+}
+
+// ---- Main ----
+
+async function fetchAllProducts() {
+  log('Fetching all Glitter Walls products...');
+  const allProducts = [];
+  let cursor = null;
+
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const query = `{
+      products(first: 25, query: "vendor:\\"Glitter Walls\\" AND status:active"${after}) {
+        edges {
+          cursor
+          node {
+            id
+            title
+            bodyHtml
+            vendor
+            tags
+            images(first: 10) {
+              edges { node { id url altText } }
+            }
+            media(first: 10) {
+              edges { node { ... on MediaImage { id image { url altText } } } }
+            }
+            variants(first: 5) {
+              edges { node { id sku title } }
+            }
+            metafields(first: 50) {
+              edges { node { namespace key value type } }
+            }
+          }
+        }
+        pageInfo { hasNextPage }
+      }
+    }`;
+
+    const r = await gql({ query });
+    const edges = r?.data?.products?.edges || [];
+
+    if (edges.length === 0) break;
+
+    for (const edge of edges) {
+      allProducts.push(edge.node);
+      cursor = edge.cursor;
+    }
+
+    log(`  Fetched ${allProducts.length} products so far...`);
+
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(600);
+  }
+
+  log(`Total products fetched: ${allProducts.length}\n`);
+  return allProducts;
+}
+
+async function processProduct(p, idx, total) {
+  const pid = p.id;
+  const origTitle = p.title;
+  log(`\n[${idx + 1}/${total}] ${origTitle}`);
+
+  // Collect metafields
+  const globals = {};
+  const customs = {};
+  for (const m of (p.metafields?.edges || [])) {
+    const { namespace, key, value } = m.node;
+    if (namespace === 'global' && value) globals[key] = value;
+    if (namespace === 'custom' && value) customs[key] = value;
+  }
+
+  // Get SKU from first variant
+  const firstVariant = p.variants?.edges?.[0]?.node;
+  const sku = firstVariant?.sku || '';
+  // Clean SKU: strip "-sample" suffix
+  const cleanSku = sku.replace(/-sample$/i, '').trim();
+
+  const result = { specs: 0, color: false, body: false, title: false, montyDate: false, alt: 0, errors: [] };
+
+  // ============================================================
+  // PHASE 1: Migrate global.* → custom.* metafields
+  // ============================================================
+  const mfToSet = [];
+  const seen = new Set();
+
+  for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
+    if (!globals[gKey]) continue;
+    if (customs[mapping.key]) continue;
+    if (seen.has(mapping.key)) continue;
+
+    const cleaned = mapping.clean(globals[gKey]);
+    if (!cleaned) continue;
+
+    mfToSet.push({
+      ownerId: pid, namespace: 'custom', key: mapping.key,
+      value: cleaned, type: mapping.type
+    });
+    seen.add(mapping.key);
+  }
+
+  if (mfToSet.length > 0) {
+    try {
+      const r = await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: mfToSet }
+      });
+      const errs = r?.data?.metafieldsSet?.userErrors || [];
+      if (errs.length > 0) {
+        result.errors.push(`Specs: ${errs[0].message}`);
+      } else {
+        result.specs = mfToSet.length;
+        log(`  Phase 1 Specs: ${mfToSet.length} fields migrated`);
+      }
+    } catch (e) {
+      result.errors.push(`Specs: ${e.message}`);
+    }
+    await sleep(400);
+  } else {
+    log(`  Phase 1 Specs: already complete`);
+  }
+
+  // ============================================================
+  // PHASE 2: AI Color Analysis via Gemini
+  // ============================================================
+  const imgUrl = p.images?.edges?.[0]?.node?.url;
+  let colorName = customs['color_name'] || globals['color'] || null;
+  let colorHex = customs['color_hex'] || null;
+  let aiDesc = '';
+
+  if (imgUrl && !customs['color_details']) {
+    try {
+      const tmpImg = '/tmp/glitter_img.jpg';
+      await downloadImage(imgUrl, tmpImg);
+      const imgB64 = fs.readFileSync(tmpImg).toString('base64');
+
+      const colorResult = await geminiCall({
+        contents: [{ parts: [
+          { text: 'Analyze this wallcovering swatch image. Return ONLY valid JSON (no markdown fencing): {"dominantColor":"ColorName","dominantHex":"#XXXXXX","backgroundColor":"ColorName","backgroundHex":"#XXXXXX","colors":[{"name":"Color Name","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Title Case color names. Be specific with color names (e.g., "Champagne Gold" not just "Gold").' },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+        ]}],
+        generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+      });
+
+      const text = colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+      const clean = text.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
+      const cd = JSON.parse(clean);
+
+      colorName = cd.dominantColor || colorName;
+      colorHex = cd.dominantHex || colorHex;
+
+      const colorMf = [
+        { ownerId: pid, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
+        { ownerId: pid, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
+        { ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
+        { ownerId: pid, namespace: 'custom', key: 'color_name', value: cd.dominantColor, type: 'single_line_text_field' },
+      ];
+      await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: colorMf }
+      });
+      result.color = true;
+      log(`  Phase 2 Color: ${cd.dominantColor} (${cd.dominantHex})`);
+
+      // Also get AI description while we have the image
+      const descResult = await geminiCall({
+        contents: [{ parts: [
+          { text: `Describe this wallcovering called "${origTitle}" by Glitter Walls in 2-3 sentences for a design-trade buyer. Mention texture, visual effect, and recommended use (commercial/hospitality/residential). Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name.` },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+        ]}],
+        generationConfig: { temperature: 0.3, maxOutputTokens: 250 }
+      });
+      aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
+
+      await sleep(1200);
+    } catch (e) {
+      result.errors.push(`Color: ${e.message?.slice(0, 60)}`);
+      log(`  Phase 2 Color: ERROR ${e.message?.slice(0, 60)}`);
+    }
+  } else if (customs['color_details']) {
+    log(`  Phase 2 Color: already done`);
+    result.color = true;
+    // If we have color_details but no color_name, try to extract it
+    if (!customs['color_name'] && customs['color_details']) {
+      try {
+        const details = JSON.parse(customs['color_details']);
+        if (Array.isArray(details) && details.length > 0) {
+          colorName = details[0].name || colorName;
+        }
+      } catch {}
+    }
+
+    // Still need AI description for body rewrite - fetch image
+    if (imgUrl) {
+      try {
+        const tmpImg = '/tmp/glitter_img.jpg';
+        await downloadImage(imgUrl, tmpImg);
+        const imgB64 = fs.readFileSync(tmpImg).toString('base64');
+        const descResult = await geminiCall({
+          contents: [{ parts: [
+            { text: `Describe this wallcovering called "${origTitle}" by Glitter Walls in 2-3 sentences for a design-trade buyer. Mention texture, visual effect, and recommended use (commercial/hospitality/residential). Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name.` },
+            { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+          ]}],
+          generationConfig: { temperature: 0.3, maxOutputTokens: 250 }
+        });
+        aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
+        await sleep(1200);
+      } catch (e) {
+        log(`  AI Desc: ERROR ${e.message?.slice(0, 40)}`);
+      }
+    }
+  } else {
+    log(`  Phase 2 Color: no image available`);
+  }
+
+  // If we still don't have a colorName, try extracting from the title
+  if (!colorName) {
+    const parsed = extractPatternAndColor(origTitle);
+    colorName = parsed.color;
+  }
+
+  // ============================================================
+  // PHASE 3: Body rewrite — exactly 2 paragraphs
+  // ============================================================
+  const { pattern, color: parsedColor } = extractPatternAndColor(origTitle);
+  // For TITLE: prefer global metafield color (original vendor data) > parsed title color > AI color_name
+  // Global metafield 'color' is the original vendor color name, never modified by our scripts
+  // Filter out generic/useless color values
+  const badColorValues = ['random', 'n/a', 'none', 'varies', 'n a', ''];
+  const globalColor = (globals['color'] || globals['Color-Way'] || '').trim();
+  const useGlobalColor = globalColor && !badColorValues.includes(globalColor.toLowerCase()) ? globalColor : null;
+  const titleColor = useGlobalColor || parsedColor || colorName || '';
+  const displayColor = titleColor;
+
+  const editorial = `${pattern}${displayColor ? ' - ' + displayColor : ''} is a premium wallcovering by Glitter Walls, designed to bring luxurious shimmer and texture to any interior. This decorative surface material is ideal for creating striking feature walls in hospitality, retail, and residential spaces.`;
+  const aiParagraph = aiDesc || 'Crafted with meticulous attention to detail, this wallcovering delivers a captivating visual presence that transforms ordinary walls into stunning focal points. Perfect for designers seeking to add depth and sophistication to commercial lobbies, boutique hotels, upscale restaurants, and elegant living spaces.';
+
+  const newBody = `<p>${editorial}</p>\n<p>${aiParagraph}</p>`;
+
+  try {
+    await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: pid, bodyHtml: newBody } }
+    });
+    result.body = true;
+    log(`  Phase 3 Body: 2 paragraphs set`);
+  } catch (e) {
+    result.errors.push(`Body: ${e.message?.slice(0, 40)}`);
+  }
+  await sleep(400);
+
+  // ============================================================
+  // PHASE 4: Title update — "Pattern - Color | Glitter Walls"
+  // ============================================================
+  const newTitle = buildTitle(pattern, titleColor);
+
+  // Only update if title actually changed
+  if (newTitle !== origTitle) {
+    try {
+      await gql({
+        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+        variables: { i: { id: pid, title: newTitle } }
+      });
+      result.title = true;
+      log(`  Phase 4 Title: "${origTitle}" → "${newTitle}"`);
+    } catch (e) {
+      result.errors.push(`Title: ${e.message?.slice(0, 40)}`);
+    }
+    await sleep(400);
+  } else {
+    log(`  Phase 4 Title: already correct`);
+    result.title = true;
+  }
+
+  // ============================================================
+  // PHASE 5: Set custom.full_monty_date
+  // ============================================================
+  if (customs['full_monty_date'] !== FULL_MONTY_DATE) {
+    try {
+      await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: [
+          { ownerId: pid, namespace: 'custom', key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' }
+        ] }
+      });
+      result.montyDate = true;
+      log(`  Phase 5 Monty date: ${FULL_MONTY_DATE}`);
+    } catch (e) {
+      result.errors.push(`MontyDate: ${e.message?.slice(0, 40)}`);
+    }
+    await sleep(300);
+  } else {
+    result.montyDate = true;
+    log(`  Phase 5 Monty date: already set`);
+  }
+
+  // ============================================================
+  // PHASE 6: Update alt tags on all images
+  // ============================================================
+  const mediaEdges = p.media?.edges || [];
+  const altBase = `${cleanSku || sku} ${pattern} ${titleColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
+
+  if (mediaEdges.length > 0) {
+    const mediaUpdates = [];
+    for (let mi = 0; mi < mediaEdges.length; mi++) {
+      const mediaNode = mediaEdges[mi].node;
+      if (!mediaNode?.id) continue;
+      const altTag = mi === 0 ? altBase : `${altBase} ${mi + 1}`;
+      mediaUpdates.push({ id: mediaNode.id, alt: altTag });
+    }
+
+    if (mediaUpdates.length > 0) {
+      try {
+        // Shopify limit: up to 10 media at a time
+        const mediaJson = mediaUpdates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`).join(', ');
+        await gql({
+          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }`
+        });
+        result.alt = mediaUpdates.length;
+        log(`  Phase 6 Alt tags: ${mediaUpdates.length} images updated`);
+      } catch (e) {
+        result.errors.push(`Alt: ${e.message?.slice(0, 40)}`);
+      }
+      await sleep(400);
+    }
+  } else {
+    log(`  Phase 6 Alt tags: no media found`);
+  }
+
+  if (result.errors.length > 0) {
+    log(`  ERRORS: ${result.errors.join(' | ')}`);
+  }
+
+  return result;
+}
+
+async function main() {
+  log('=== Glitter Walls Full Monty (NO Room Settings) ===');
+  log(`Date: ${FULL_MONTY_DATE}`);
+  log(`Store: ${STORE}\n`);
+
+  const products = await fetchAllProducts();
+
+  const stats = {
+    total: products.length,
+    specsFieldsMigrated: 0,
+    colorsAnalyzed: 0,
+    bodiesRewritten: 0,
+    titlesUpdated: 0,
+    montyDatesSet: 0,
+    altTagsUpdated: 0,
+    errors: 0,
+    skipped: 0,
+  };
+
+  for (let i = 0; i < products.length; i++) {
+    try {
+      const result = await processProduct(products[i], i, products.length);
+      stats.specsFieldsMigrated += result.specs;
+      if (result.color) stats.colorsAnalyzed++;
+      if (result.body) stats.bodiesRewritten++;
+      if (result.title) stats.titlesUpdated++;
+      if (result.montyDate) stats.montyDatesSet++;
+      stats.altTagsUpdated += result.alt;
+      stats.errors += result.errors.length;
+    } catch (e) {
+      log(`  FATAL ERROR on product ${i + 1}: ${e.message}`);
+      stats.errors++;
+    }
+
+    // Brief pause between products to avoid rate limits
+    await sleep(200);
+
+    // Progress every 25
+    if ((i + 1) % 25 === 0) {
+      log(`\n--- PROGRESS: ${i + 1}/${products.length} (${((i + 1) / products.length * 100).toFixed(1)}%) ---`);
+      log(`  Specs fields: ${stats.specsFieldsMigrated} | Colors: ${stats.colorsAnalyzed} | Bodies: ${stats.bodiesRewritten} | Titles: ${stats.titlesUpdated} | Alt tags: ${stats.altTagsUpdated} | Errors: ${stats.errors}\n`);
+    }
+  }
+
+  const summary = `
+=== COMPLETE ===
+Total products: ${stats.total}
+Spec fields migrated: ${stats.specsFieldsMigrated}
+Colors analyzed: ${stats.colorsAnalyzed}
+Bodies rewritten: ${stats.bodiesRewritten}
+Titles updated: ${stats.titlesUpdated}
+Monty dates set: ${stats.montyDatesSet}
+Alt tags updated: ${stats.altTagsUpdated}
+Errors: ${stats.errors}
+Finished: ${new Date().toISOString()}
+`;
+
+  log(summary);
+}
+
+main().catch(e => { log(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..a0a5fa7
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,37 @@
+/**
+ * Authentication System for Letsbegin
+ * Uses same credentials as DW-Agents for consistency
+ */
+
+export const AUTH_CONFIG = {
+  username: 'admin',
+  password: requireEnv('AUTH_PASSWORD'),
+  sessionName: 'letsbegin_session',
+  sessionSecret: requireEnv('SESSION_SECRET'),
+  cookieMaxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
+}
+
+function requireEnv(name: string): string {
+  const value = process.env[name]
+  if (!value) {
+    throw new Error(`${name} environment variable is required`)
+  }
+  return value
+}
+
+// IPs that bypass authentication (localhost, server itself)
+export const WHITELISTED_IPS = ['127.0.0.1', '::1', '45.61.58.125', 'localhost']
+
+export interface AuthSession {
+  authenticated: boolean
+  username?: string
+  loginTime?: number
+}
+
+export function validateCredentials(username: string, password: string): boolean {
+  return username === AUTH_CONFIG.username && password === AUTH_CONFIG.password
+}
+
+export function isWhitelistedIP(ip: string): boolean {
+  return WHITELISTED_IPS.includes(ip)
+}
diff --git a/lib/gemini-image.ts b/lib/gemini-image.ts
new file mode 100644
index 0000000..dca163b
--- /dev/null
+++ b/lib/gemini-image.ts
@@ -0,0 +1,157 @@
+/**
+ * Gemini Image Analysis Utility
+ * Uses Gemini 2.0 Flash for all image-related AI tasks
+ * FREE - no credits needed!
+ */
+
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY
+if (!GEMINI_API_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required')
+}
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent'
+
+export interface ImageAnalysisResult {
+  description: string
+  colors: string[]
+  styles: string[]
+  patterns: string[]
+  backgroundColor: string
+  tags: string[]
+}
+
+/**
+ * Analyze an image using Gemini Vision
+ * @param imageBase64 - Base64 encoded image data (without data URL prefix)
+ * @param mimeType - Image MIME type (image/jpeg, image/png, etc.)
+ * @param prompt - Custom prompt for analysis
+ */
+export async function analyzeImage(
+  imageBase64: string,
+  mimeType: string = 'image/jpeg',
+  prompt?: string
+): Promise<string> {
+  const defaultPrompt = prompt || 'Describe this image in detail.'
+
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{
+        parts: [
+          { inlineData: { mimeType, data: imageBase64 } },
+          { text: defaultPrompt }
+        ]
+      }],
+      generationConfig: { temperature: 0.1, maxOutputTokens: 1000 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini Vision API error: ${error}`)
+  }
+
+  const data = await response.json()
+  return data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+}
+
+/**
+ * Analyze wallcovering/wallpaper image for interior design tagging
+ * Returns structured analysis with colors, styles, patterns
+ */
+export async function analyzeWallcovering(
+  imageBase64: string,
+  productTitle: string,
+  mimeType: string = 'image/jpeg'
+): Promise<ImageAnalysisResult> {
+  const prompt = `As an expert interior designer, analyze this wallcovering "${productTitle}".
+Return ONLY valid JSON with this exact structure:
+{
+  "backgroundColor": "single dominant background color",
+  "colors": ["max 3 most visible colors"],
+  "styles": ["1-2 design styles from: Traditional, Contemporary, Coastal, Tropical, Art Deco, Mid-Century Modern, Bohemian, Transitional, Victorian, Chinoiserie, Retro, Pop Art"],
+  "patterns": ["1-2 pattern types from: Botanical, Floral, Geometric, Damask, Palm, Trellis, Stripe, Abstract, Toile, Scenic, Solid, Textured"],
+  "tags": ["3-5 interior design tags for search"],
+  "description": "2-3 sentence professional interior designer description for commercial applications"
+}`
+
+  const response = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{
+        parts: [
+          { inlineData: { mimeType, data: imageBase64 } },
+          { text: prompt }
+        ]
+      }],
+      generationConfig: { temperature: 0.1, maxOutputTokens: 800 }
+    })
+  })
+
+  if (!response.ok) {
+    const error = await response.text()
+    throw new Error(`Gemini Vision API error: ${error}`)
+  }
+
+  const data = await response.json()
+  const text = data.candidates?.[0]?.content?.parts?.[0]?.text || ''
+
+  // Parse JSON from response
+  try {
+    const jsonMatch = text.match(/\{[\s\S]*\}/)
+    if (jsonMatch) {
+      return JSON.parse(jsonMatch[0])
+    }
+  } catch (e) {
+    console.error('Failed to parse Gemini response:', e)
+  }
+
+  // Return default structure if parsing fails
+  return {
+    description: text,
+    colors: [],
+    styles: [],
+    patterns: [],
+    backgroundColor: '',
+    tags: []
+  }
+}
+
+/**
+ * Get image from URL as base64
+ */
+export async function fetchImageAsBase64(imageUrl: string): Promise<{ base64: string; mimeType: string }> {
+  const response = await fetch(imageUrl)
+  if (!response.ok) {
+    throw new Error(`Failed to fetch image: ${response.status}`)
+  }
+
+  const buffer = await response.arrayBuffer()
+  const base64 = Buffer.from(buffer).toString('base64')
+  const mimeType = response.headers.get('content-type') || 'image/jpeg'
+
+  return { base64, mimeType }
+}
+
+/**
+ * Analyze image from URL
+ */
+export async function analyzeImageFromUrl(
+  imageUrl: string,
+  prompt?: string
+): Promise<string> {
+  const { base64, mimeType } = await fetchImageAsBase64(imageUrl)
+  return analyzeImage(base64, mimeType, prompt)
+}
+
+/**
+ * Analyze wallcovering from URL
+ */
+export async function analyzeWallcoveringFromUrl(
+  imageUrl: string,
+  productTitle: string
+): Promise<ImageAnalysisResult> {
+  const { base64, mimeType } = await fetchImageAsBase64(imageUrl)
+  return analyzeWallcovering(base64, productTitle, mimeType)
+}
diff --git a/lib/git.ts b/lib/git.ts
new file mode 100644
index 0000000..e6777b7
--- /dev/null
+++ b/lib/git.ts
@@ -0,0 +1,122 @@
+/**
+ * Git integration for auto-committing on successful steps
+ */
+
+import { exec } from 'child_process'
+import { promisify } from 'util'
+
+const execAsync = promisify(exec)
+
+interface CommitResult {
+  success: boolean
+  message: string
+  sha?: string
+}
+
+export async function autoCommit(
+  projectPath: string,
+  message: string,
+  files?: string[]
+): Promise<CommitResult> {
+  try {
+    // Check if it's a git repo
+    try {
+      await execAsync(`cd "${projectPath}" && git rev-parse --is-inside-work-tree`)
+    } catch {
+      return { success: false, message: 'Not a git repository' }
+    }
+
+    // Stage files
+    if (files && files.length > 0) {
+      for (const file of files) {
+        await execAsync(`cd "${projectPath}" && git add "${file}"`)
+      }
+    } else {
+      // Stage all changes
+      await execAsync(`cd "${projectPath}" && git add -A`)
+    }
+
+    // Check if there are changes to commit
+    const { stdout: status } = await execAsync(`cd "${projectPath}" && git status --porcelain`)
+    if (!status.trim()) {
+      return { success: true, message: 'No changes to commit' }
+    }
+
+    // Create commit
+    const commitMessage = `${message}\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>`
+    const { stdout } = await execAsync(
+      `cd "${projectPath}" && git commit -m "${commitMessage.replace(/"/g, '\\"')}"`,
+      { maxBuffer: 10 * 1024 * 1024 }
+    )
+
+    // Get commit SHA
+    const { stdout: sha } = await execAsync(`cd "${projectPath}" && git rev-parse --short HEAD`)
+
+    return {
+      success: true,
+      message: 'Committed successfully',
+      sha: sha.trim()
+    }
+  } catch (error) {
+    console.error('Git commit error:', error)
+    return {
+      success: false,
+      message: error instanceof Error ? error.message : 'Unknown error'
+    }
+  }
+}
+
+export async function createBranch(projectPath: string, branchName: string): Promise<CommitResult> {
+  try {
+    // Check if branch exists
+    try {
+      await execAsync(`cd "${projectPath}" && git show-ref --verify --quiet refs/heads/${branchName}`)
+      // Branch exists, checkout
+      await execAsync(`cd "${projectPath}" && git checkout ${branchName}`)
+      return { success: true, message: `Switched to existing branch: ${branchName}` }
+    } catch {
+      // Branch doesn't exist, create it
+      await execAsync(`cd "${projectPath}" && git checkout -b ${branchName}`)
+      return { success: true, message: `Created and switched to branch: ${branchName}` }
+    }
+  } catch (error) {
+    console.error('Git branch error:', error)
+    return {
+      success: false,
+      message: error instanceof Error ? error.message : 'Unknown error'
+    }
+  }
+}
+
+export async function getGitStatus(projectPath: string): Promise<{
+  isRepo: boolean
+  branch: string | null
+  hasChanges: boolean
+  changedFiles: string[]
+}> {
+  try {
+    // Check if it's a git repo
+    await execAsync(`cd "${projectPath}" && git rev-parse --is-inside-work-tree`)
+
+    // Get current branch
+    const { stdout: branch } = await execAsync(`cd "${projectPath}" && git branch --show-current`)
+
+    // Get changed files
+    const { stdout: status } = await execAsync(`cd "${projectPath}" && git status --porcelain`)
+    const changedFiles = status.trim().split('\n').filter(Boolean).map(line => line.slice(3))
+
+    return {
+      isRepo: true,
+      branch: branch.trim() || 'detached',
+      hasChanges: changedFiles.length > 0,
+      changedFiles
+    }
+  } catch {
+    return {
+      isRepo: false,
+      branch: null,
+      hasChanges: false,
+      changedFiles: []
+    }
+  }
+}
diff --git a/lib/llm-local.ts b/lib/llm-local.ts
new file mode 100644
index 0000000..126455d
--- /dev/null
+++ b/lib/llm-local.ts
@@ -0,0 +1,181 @@
+/**
+ * lib/llm-local.ts
+ *
+ * Thin compatibility shim that talks to a local Ollama server using an
+ * Anthropic-style messages interface. Lets call sites swap
+ * `anthropic.messages.create({...})` for `chat([...], {...})` without
+ * rewriting business logic.
+ *
+ * Default model: qwen3:14b (good general-purpose, follows instructions).
+ * Override with opts.model or env OLLAMA_MODEL.
+ *
+ * Env:
+ *   OLLAMA_URL   default http://127.0.0.1:11434
+ *   OLLAMA_MODEL default qwen3:14b
+ *   USE_ANTHROPIC=1   call sites may gate on this if they want to keep
+ *                     the Anthropic SDK as a fallback path.
+ */
+
+export type Msg = {
+  role: 'system' | 'user' | 'assistant'
+  content: string
+}
+
+export interface ChatOpts {
+  model?: string
+  temperature?: number
+  maxTokens?: number
+  system?: string
+  format?: 'json' | undefined
+  // Allow extra Ollama options to flow through.
+  options?: Record<string, unknown>
+}
+
+const DEFAULT_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434'
+const DEFAULT_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b'
+
+let _healthCache: { ok: boolean; ts: number } | null = null
+const HEALTH_TTL_MS = 30_000
+
+async function healthCheck(): Promise<void> {
+  const now = Date.now()
+  if (_healthCache && now - _healthCache.ts < HEALTH_TTL_MS && _healthCache.ok) {
+    return
+  }
+  try {
+    const r = await fetch(`${DEFAULT_URL}/api/tags`, { method: 'GET' })
+    if (!r.ok) {
+      _healthCache = { ok: false, ts: now }
+      throw new Error(`Ollama health check failed: HTTP ${r.status}`)
+    }
+    _healthCache = { ok: true, ts: now }
+  } catch (err: any) {
+    _healthCache = { ok: false, ts: now }
+    throw new Error(
+      `Ollama unreachable at ${DEFAULT_URL}. Start it with \`ollama serve\` ` +
+        `or set OLLAMA_URL. Underlying error: ${err?.message || err}`,
+    )
+  }
+}
+
+function normalizeMessages(messages: Msg[], system?: string): Msg[] {
+  const out: Msg[] = []
+  if (system && system.trim().length > 0) {
+    out.push({ role: 'system', content: system })
+  }
+  for (const m of messages) {
+    // Ollama expects plain string content; if a caller accidentally hands us
+    // an Anthropic-style content-block array, flatten the text parts.
+    const c: any = (m as any).content
+    let text: string
+    if (typeof c === 'string') {
+      text = c
+    } else if (Array.isArray(c)) {
+      text = c
+        .map((p: any) =>
+          typeof p === 'string' ? p : p?.text || p?.content || '',
+        )
+        .filter(Boolean)
+        .join('\n')
+    } else {
+      text = String(c ?? '')
+    }
+    out.push({ role: m.role, content: text })
+  }
+  return out
+}
+
+export async function chat(
+  messages: Msg[],
+  opts: ChatOpts = {},
+): Promise<{ content: string; raw: any }> {
+  await healthCheck()
+
+  const body: any = {
+    model: opts.model || DEFAULT_MODEL,
+    messages: normalizeMessages(messages, opts.system),
+    stream: false,
+    options: {
+      ...(opts.options || {}),
+      ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
+      ...(opts.maxTokens !== undefined ? { num_predict: opts.maxTokens } : {}),
+    },
+  }
+  if (opts.format === 'json') {
+    body.format = 'json'
+  }
+
+  const r = await fetch(`${DEFAULT_URL}/api/chat`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+
+  if (!r.ok) {
+    const txt = await r.text().catch(() => '')
+    throw new Error(`Ollama /api/chat HTTP ${r.status}: ${txt.slice(0, 500)}`)
+  }
+
+  const json = await r.json()
+  const content: string = json?.message?.content ?? ''
+  return { content, raw: json }
+}
+
+export async function* chatStream(
+  messages: Msg[],
+  opts: ChatOpts = {},
+): AsyncGenerator<string> {
+  await healthCheck()
+
+  const body: any = {
+    model: opts.model || DEFAULT_MODEL,
+    messages: normalizeMessages(messages, opts.system),
+    stream: true,
+    options: {
+      ...(opts.options || {}),
+      ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
+      ...(opts.maxTokens !== undefined ? { num_predict: opts.maxTokens } : {}),
+    },
+  }
+  if (opts.format === 'json') {
+    body.format = 'json'
+  }
+
+  const r = await fetch(`${DEFAULT_URL}/api/chat`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+  if (!r.ok || !r.body) {
+    const txt = await r.text().catch(() => '')
+    throw new Error(`Ollama /api/chat (stream) HTTP ${r.status}: ${txt.slice(0, 500)}`)
+  }
+
+  const reader = r.body.getReader()
+  const decoder = new TextDecoder()
+  let buf = ''
+  while (true) {
+    const { done, value } = await reader.read()
+    if (done) break
+    buf += decoder.decode(value, { stream: true })
+    let idx: number
+    while ((idx = buf.indexOf('\n')) >= 0) {
+      const line = buf.slice(0, idx).trim()
+      buf = buf.slice(idx + 1)
+      if (!line) continue
+      try {
+        const obj = JSON.parse(line)
+        const piece: string = obj?.message?.content || ''
+        if (piece) yield piece
+        if (obj?.done) return
+      } catch {
+        // ignore partial chunks
+      }
+    }
+  }
+}
+
+export const __config = {
+  url: DEFAULT_URL,
+  model: DEFAULT_MODEL,
+}
diff --git a/lib/sheets.ts b/lib/sheets.ts
new file mode 100644
index 0000000..aac707d
--- /dev/null
+++ b/lib/sheets.ts
@@ -0,0 +1,117 @@
+/**
+ * Google Sheets integration for tracking project progress
+ * Uses the existing Google Sheets API configuration
+ */
+
+const SHEETS_WEBHOOK_URL = process.env.GOOGLE_SHEETS_WEBHOOK_URL || ''
+const SHEETS_ID = process.env.GOOGLE_SHEETS_ID || ''
+
+interface TrackingEvent {
+  timestamp: string
+  project: string
+  step: 'prd_generated' | 'ralph_converted' | 'ralph_started' | 'story_completed' | 'ralph_finished'
+  details: string
+  status: 'success' | 'error' | 'in_progress'
+  storyId?: string
+  duration?: number
+}
+
+export async function trackEvent(event: TrackingEvent): Promise<boolean> {
+  try {
+    // Store locally in JSON file
+    const fs = await import('fs')
+    const path = await import('path')
+
+    const trackingDir = path.join(process.cwd(), 'data')
+    const trackingFile = path.join(trackingDir, 'tracking.json')
+
+    if (!fs.existsSync(trackingDir)) {
+      fs.mkdirSync(trackingDir, { recursive: true })
+    }
+
+    let events: TrackingEvent[] = []
+    if (fs.existsSync(trackingFile)) {
+      events = JSON.parse(fs.readFileSync(trackingFile, 'utf8'))
+    }
+
+    events.push({
+      ...event,
+      timestamp: event.timestamp || new Date().toISOString()
+    })
+
+    // Keep last 1000 events
+    if (events.length > 1000) {
+      events = events.slice(-1000)
+    }
+
+    fs.writeFileSync(trackingFile, JSON.stringify(events, null, 2))
+
+    // If Google Sheets webhook is configured, send there too
+    if (SHEETS_WEBHOOK_URL) {
+      await fetch(SHEETS_WEBHOOK_URL, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          values: [[
+            event.timestamp,
+            event.project,
+            event.step,
+            event.details,
+            event.status,
+            event.storyId || '',
+            event.duration || ''
+          ]]
+        })
+      })
+    }
+
+    return true
+  } catch (error) {
+    console.error('Failed to track event:', error)
+    return false
+  }
+}
+
+export async function getTrackingEvents(project?: string, limit = 50): Promise<TrackingEvent[]> {
+  try {
+    const fs = await import('fs')
+    const path = await import('path')
+
+    const trackingFile = path.join(process.cwd(), 'data', 'tracking.json')
+
+    if (!fs.existsSync(trackingFile)) {
+      return []
+    }
+
+    let events: TrackingEvent[] = JSON.parse(fs.readFileSync(trackingFile, 'utf8'))
+
+    if (project) {
+      events = events.filter(e => e.project === project)
+    }
+
+    return events.slice(-limit).reverse()
+  } catch (error) {
+    console.error('Failed to get tracking events:', error)
+    return []
+  }
+}
+
+export async function getProjectStats(project: string): Promise<{
+  totalPRDs: number
+  totalRalphRuns: number
+  successRate: number
+  lastActivity: string | null
+}> {
+  const events = await getTrackingEvents(project, 1000)
+
+  const prdEvents = events.filter(e => e.step === 'prd_generated')
+  const ralphRuns = events.filter(e => e.step === 'ralph_finished')
+  const successfulRuns = ralphRuns.filter(e => e.status === 'success')
+
+  return {
+    totalPRDs: prdEvents.length,
+    totalRalphRuns: ralphRuns.length,
+    successRate: ralphRuns.length > 0 ? (successfulRuns.length / ralphRuns.length) * 100 : 0,
+    lastActivity: events[0]?.timestamp || null
+  }
+}
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..d084cca
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..b5053b7
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,100 @@
+import { NextResponse } from 'next/server'
+import type { NextRequest } from 'next/server'
+import { AUTH_CONFIG } from '@/lib/auth'
+
+// Routes that don't require authentication
+const publicPaths = [
+  '/login',
+  '/live',
+  '/api/auth/login',
+  '/api/auth/logout',
+  '/api/auth/session',
+  '/api/processes',
+  '/api/ralph',
+  '/api/live',
+  '/api/url-info',
+  '/_next',
+  '/favicon.ico',
+]
+
+// DW Central SSO: Check if user has a valid DW Central session
+function verifyDWCentralSession(token: string): boolean {
+  try {
+    // DW Central uses base64 encoded "username:timestamp" format
+    const decoded = Buffer.from(token, 'base64').toString('utf-8');
+    const [username, timestamp] = decoded.split(':');
+    if (username && timestamp) {
+      const sessionTime = parseInt(timestamp, 10);
+      const now = Date.now();
+      const maxAge = 86400000; // 24 hours
+      return (now - sessionTime) < maxAge;
+    }
+    return false;
+  } catch {
+    return false;
+  }
+}
+
+export function middleware(request: NextRequest) {
+  const { pathname } = request.nextUrl
+
+  // Allow public paths
+  if (publicPaths.some(path => pathname.startsWith(path))) {
+    return NextResponse.next()
+  }
+
+  // Check for DW Central SSO session first (from dw.greendomainbrokers.com)
+  const dwCentralToken = request.cookies.get('dw_central_session')?.value;
+  if (dwCentralToken && verifyDWCentralSession(dwCentralToken)) {
+    return NextResponse.next()
+  }
+
+  // Bypass internal auth if already authenticated via nginx basic auth (DW Central portal)
+  const authHeader = request.headers.get('authorization')
+  if (authHeader?.startsWith('Basic ')) {
+    return NextResponse.next()
+  }
+
+  // Check for session cookie
+  const sessionCookie = request.cookies.get(AUTH_CONFIG.sessionName)
+
+  if (!sessionCookie?.value) {
+    // No session - redirect to login
+    const loginUrl = new URL('/login', request.url)
+    loginUrl.searchParams.set('redirect', pathname)
+    return NextResponse.redirect(loginUrl)
+  }
+
+  // Validate session token
+  try {
+    const sessionData = JSON.parse(Buffer.from(sessionCookie.value, 'base64').toString())
+
+    if (!sessionData.authenticated) {
+      // Invalid session - redirect to login
+      const loginUrl = new URL('/login', request.url)
+      return NextResponse.redirect(loginUrl)
+    }
+
+    // Check session age (30 days max)
+    const sessionAge = Date.now() - sessionData.loginTime
+    if (sessionAge > AUTH_CONFIG.cookieMaxAge) {
+      // Session expired - redirect to login
+      const loginUrl = new URL('/login', request.url)
+      return NextResponse.redirect(loginUrl)
+    }
+
+    // Valid session - proceed
+    return NextResponse.next()
+  } catch (error) {
+    // Invalid session data - redirect to login
+    const loginUrl = new URL('/login', request.url)
+    return NextResponse.redirect(loginUrl)
+  }
+}
+
+export const config = {
+  matcher: [
+    // Match all paths except static files and images
+    '/((?!_next/static|_next/image|favicon.ico).*)',
+  ],
+}
diff --git a/migrate_global_to_custom.js b/migrate_global_to_custom.js
new file mode 100644
index 0000000..5a8dfdf
--- /dev/null
+++ b/migrate_global_to_custom.js
@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+/**
+ * Global → Custom Metafield Migrator
+ * Copies spec data from global.* to custom.* where custom is missing
+ * Easy lifts first: only fills gaps (doesn't overwrite existing custom.* values)
+ *
+ * Mapping:
+ *   global.width → custom.width
+ *   global.repeat / global.Vert-Rpt → custom.pattern_repeat
+ *   global.fire_rating / global.FLAMMABILITY → custom.fire_rating
+ *   global.Contents / global.Content / global.Construction → custom.material
+ *   global.Collection → custom.collection_name
+ *   global.Brand → custom.brand
+ *   global.MATCH / global.Match → custom.match_type
+ *   global.Finish / global.FINISH → custom.finish
+ *   global.Cleaning → custom.care
+ *   global.application → custom.application
+ *   global.Substrate → custom.backing
+ *   global.length / global.Length → custom.length
+ *   global.packaged / global.Packaged → custom.packaging
+ *   global.unit_of_measure → custom.unit_of_measure
+ *   global.Weight → custom.product_weight
+ *   global.Country / global.Country-of-Origin → custom.origin
+ *   global.manufacturer_sku / global.vendor_name_internal → custom.manufacturer_sku (if custom is just a number)
+ */
+
+const https = require('https');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = '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';
+
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function cleanWidth(v) {
+  if (!v) return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+function cleanFireRating(v) {
+  if (!v) return null;
+  let f = v.replace(/["']/g, '').trim();
+  if (f.length > 100) return null; // junk data
+  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
+  return f || null;
+}
+
+function cleanMaterial(v) {
+  if (!v) return null;
+  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
+}
+
+function cleanRepeat(v) {
+  if (!v || v === 'N/A' || v === '0') return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+// Map: globalKey → { customKey, cleaner, type }
+const FIELD_MAP = {
+  'width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'Width': { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Rpt': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Repeat': { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'fire_rating': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'FLAMMABILITY': { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'Contents': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Content': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Construction': { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Collection': { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Brand': { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'MATCH': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Match': { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Finish': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'FINISH': { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Cleaning': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Clean-Code': { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'application': { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'substrate': { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Length': { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Packaged': { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'unit_of_measure': { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Weight': { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country-of-Origin': { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+};
+
+async function main() {
+  console.log('=== Global → Custom Metafield Migrator ===');
+  console.log('Easy lifts: fill custom.* gaps from global.* data\n');
+
+  let cursor = null, page = 1, migrated = 0, skipped = 0, total = 0, errors = 0, fieldsSet = 0;
+
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({
+      query: `{ products(first: 25, query: "status:active"${after}) {
+        edges { cursor node { id metafields(first: 50) { edges { node { namespace key value } } } } }
+        pageInfo { hasNextPage }
+      } }`
+    });
+
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+
+    for (const edge of edges) {
+      const p = edge.node;
+      cursor = edge.cursor;
+      total++;
+
+      // Collect all metafields by namespace
+      const globals = {};
+      const customs = {};
+      for (const m of p.metafields.edges) {
+        const { namespace, key, value } = m.node;
+        if (namespace === 'global' && value) globals[key] = value;
+        if (namespace === 'custom' && value) customs[key] = value;
+      }
+
+      // Build list of custom.* fields to set (only where custom is missing)
+      const toSet = [];
+      const seen = new Set(); // avoid duplicate custom keys
+
+      for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
+        if (!globals[gKey]) continue; // no global value
+        if (customs[mapping.key]) continue; // custom already has value
+        if (seen.has(mapping.key)) continue; // already queued from another global key
+
+        const cleaned = mapping.clean(globals[gKey]);
+        if (!cleaned) continue;
+
+        toSet.push({
+          ownerId: p.id,
+          namespace: 'custom',
+          key: mapping.key,
+          value: cleaned,
+          type: mapping.type
+        });
+        seen.add(mapping.key);
+      }
+
+      // Special: manufacturer_sku — replace number-only values with real MFR
+      const realMfr = globals['manufacturer_sku'] || globals['vendor_name_internal'];
+      const currentMfr = customs['manufacturer_sku'] || '';
+      if (realMfr && realMfr.length > 3 && /[A-Za-z]/.test(realMfr) && !/[A-Za-z]/.test(currentMfr)) {
+        // Current is number-only, real has letters — replace
+        const cleanMfr = realMfr.replace(/\.jpg$/i, '').replace(/\.png$/i, '').trim();
+        if (cleanMfr.length > 3) {
+          toSet.push({
+            ownerId: p.id, namespace: 'custom', key: 'manufacturer_sku',
+            value: cleanMfr, type: 'single_line_text_field'
+          });
+        }
+      }
+
+      if (toSet.length === 0) { skipped++; continue; }
+
+      try {
+        const result = await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: toSet }
+        });
+        const errs = result?.data?.metafieldsSet?.userErrors || [];
+        if (errs.length > 0) {
+          errors++;
+          if (errors <= 5) console.log(`  ERR: ${errs[0].message}`);
+        } else {
+          migrated++;
+          fieldsSet += toSet.length;
+        }
+      } catch { errors++; }
+
+      await sleep(400);
+    }
+
+    if (page % 10 === 0 || !r?.data?.products?.pageInfo?.hasNextPage) {
+      console.log(`Page ${page}: total=${total} migrated=${migrated} (${fieldsSet} fields) skipped=${skipped} errors=${errors}`);
+    }
+
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    page++;
+    await sleep(200);
+  }
+
+  console.log(`\n=== DONE ===`);
+  console.log(`Total products: ${total}`);
+  console.log(`Products with gaps filled: ${migrated} (${fieldsSet} total fields)`);
+  console.log(`Already complete: ${skipped}`);
+  console.log(`Errors: ${errors}`);
+}
+
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..3d68ef0
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,17 @@
+import type { NextConfig } from 'next'
+import path from 'path'
+
+const nextConfig: NextConfig = {
+  reactStrictMode: true,
+  // Fix workspace root warning
+  outputFileTracingRoot: path.join(__dirname),
+  // No basePath - access directly via IP:7300
+  // For DW Central proxy, configure nginx to rewrite paths
+  experimental: {
+    serverActions: {
+      bodySizeLimit: '2mb',
+    },
+  },
+}
+
+export default nextConfig
diff --git a/nina_crop.js b/nina_crop.js
new file mode 100644
index 0000000..eb5c81f
--- /dev/null
+++ b/nina_crop.js
@@ -0,0 +1,447 @@
+#!/usr/bin/env node
+/**
+ * Nina Campbell Image Cropper
+ *
+ * Fetches all Nina Campbell products from Shopify, downloads each product's
+ * first image, crops it to a square (width x width from top), uploads the
+ * cropped image, reorders it to position 1, and deletes the old image.
+ *
+ * Skips images that are already square (aspect ratio 0.9 - 1.1).
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+const API_VER = '2024-10';
+const LOG_FILE = '/tmp/nina_crop.log';
+const TMP_DIR = '/tmp/nina_crop_tmp';
+
+// Ensure tmp dir exists
+if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });
+
+// Logging
+function log(msg) {
+  const line = `[${new Date().toISOString()}] ${msg}`;
+  console.log(line);
+  fs.appendFileSync(LOG_FILE, line + '\n');
+}
+
+// GraphQL helper using native https
+function gql(query, variables) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables });
+    const req = https.request({
+      hostname: STORE,
+      path: `/admin/api/${API_VER}/graphql.json`,
+      method: 'POST',
+      headers: {
+        'X-Shopify-Access-Token': TOKEN,
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(body),
+      },
+    }, (res) => {
+      let data = '';
+      res.on('data', (chunk) => data += chunk);
+      res.on('end', () => {
+        try {
+          resolve(JSON.parse(data));
+        } catch (e) {
+          reject(new Error(`JSON parse error: ${data.slice(0, 200)}`));
+        }
+      });
+    });
+    req.on('error', reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+// Download file following redirects
+function download(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close();
+        fs.unlinkSync(dest);
+        return download(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      if (res.statusCode !== 200) {
+        file.close();
+        return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
+      }
+      res.pipe(file);
+      file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', (e) => { file.close(); reject(e); });
+  });
+}
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// Fetch ALL Nina Campbell products with pagination
+async function fetchAllProducts() {
+  const products = [];
+  let cursor = null;
+  let page = 0;
+
+  while (true) {
+    page++;
+    const afterClause = cursor ? `, after: "${cursor}"` : '';
+    const query = `{
+      products(first: 50, query: "vendor:'Nina Campbell'"${afterClause}) {
+        edges {
+          cursor
+          node {
+            id
+            title
+            media(first: 10) {
+              edges {
+                node {
+                  ... on MediaImage {
+                    id
+                    image {
+                      url
+                      width
+                      height
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+        pageInfo {
+          hasNextPage
+        }
+      }
+    }`;
+
+    const res = await gql(query);
+    if (res.errors) {
+      log(`GraphQL error on page ${page}: ${JSON.stringify(res.errors)}`);
+      break;
+    }
+
+    const edges = res?.data?.products?.edges || [];
+    for (const edge of edges) {
+      products.push(edge.node);
+      cursor = edge.cursor;
+    }
+
+    log(`Fetched page ${page}: ${edges.length} products (total: ${products.length})`);
+
+    if (!res?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(300);
+  }
+
+  return products;
+}
+
+// Get image dimensions using ImageMagick identify
+function getImageDimensions(filePath) {
+  const output = execFileSync('identify', ['-format', '%wx%h', filePath], {
+    timeout: 10000,
+    encoding: 'utf8',
+  }).trim();
+  // Handle multi-frame images (animated gifs etc) - take first frame
+  const firstFrame = output.split('\n')[0].split(/\s/)[0];
+  const [w, h] = firstFrame.split('x').map(Number);
+  return { width: w, height: h };
+}
+
+// Crop image to square from top
+function cropToSquare(inputPath, outputPath, width) {
+  execFileSync('convert', [
+    inputPath,
+    '-crop', `${width}x${width}+0+0`,
+    '+repage',
+    '-quality', '93',
+    outputPath,
+  ], { timeout: 30000 });
+}
+
+// Staged upload for a file
+async function stagedUpload(filePath, filename) {
+  const fileSize = fs.statSync(filePath).size;
+  const mimeType = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';
+
+  const mutation = `mutation {
+    stagedUploadsCreate(input: [{
+      resource: IMAGE,
+      filename: "${filename}",
+      mimeType: "${mimeType}",
+      httpMethod: POST,
+      fileSize: "${fileSize}"
+    }]) {
+      stagedTargets {
+        url
+        parameters { name value }
+        resourceUrl
+      }
+      userErrors { message }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const target = res?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+  if (!target) {
+    throw new Error(`Staged upload failed: ${JSON.stringify(res?.data?.stagedUploadsCreate?.userErrors || res?.errors)}`);
+  }
+
+  // Upload via curl execFileSync
+  const curlArgs = ['-s', '-X', 'POST', target.url];
+  for (const param of target.parameters) {
+    curlArgs.push('-F', `${param.name}=${param.value}`);
+  }
+  curlArgs.push('-F', `file=@${filePath}`);
+  execFileSync('curl', curlArgs, { timeout: 60000 });
+
+  return target.resourceUrl;
+}
+
+// Create media on product
+async function createMedia(productGid, resourceUrl, altText) {
+  const mutation = `mutation {
+    productCreateMedia(productId: "${productGid}", media: [{
+      originalSource: "${resourceUrl}",
+      mediaContentType: IMAGE,
+      alt: "${altText.replace(/"/g, '\\"')}"
+    }]) {
+      media {
+        id
+      }
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const media = res?.data?.productCreateMedia?.media;
+  const errors = res?.data?.productCreateMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Create media error: ${errors.map(e => e.message).join(', ')}`);
+  }
+  if (!media || media.length === 0) {
+    throw new Error(`No media returned: ${JSON.stringify(res)}`);
+  }
+  return media[0].id;
+}
+
+// Wait for media to be READY (poll)
+async function waitForMediaReady(productGid, mediaGid, maxWait = 30000) {
+  const start = Date.now();
+  while (Date.now() - start < maxWait) {
+    const query = `{
+      product(id: "${productGid}") {
+        media(first: 20) {
+          edges {
+            node {
+              ... on MediaImage {
+                id
+                status
+              }
+            }
+          }
+        }
+      }
+    }`;
+    const res = await gql(query);
+    const edges = res?.data?.product?.media?.edges || [];
+    for (const edge of edges) {
+      if (edge.node.id === mediaGid) {
+        if (edge.node.status === 'READY') return true;
+        if (edge.node.status === 'FAILED') return false;
+      }
+    }
+    await sleep(2000);
+  }
+  return false; // timeout
+}
+
+// Reorder media so new image is first
+async function reorderMedia(productGid, allMediaIds) {
+  // allMediaIds should be in desired order (new image first)
+  const moves = allMediaIds.map((id, idx) => `{id: "${id}", newPosition: "${idx}"}`);
+  const mutation = `mutation {
+    productReorderMedia(id: "${productGid}", moves: [${moves.join(',')}]) {
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const errors = res?.data?.productReorderMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Reorder error: ${errors.map(e => e.message).join(', ')}`);
+  }
+}
+
+// Delete media from product
+async function deleteMedia(productGid, mediaIds) {
+  const idsStr = mediaIds.map(id => `"${id}"`).join(',');
+  const mutation = `mutation {
+    productDeleteMedia(productId: "${productGid}", mediaIds: [${idsStr}]) {
+      deletedMediaIds
+      mediaUserErrors {
+        message
+      }
+    }
+  }`;
+
+  const res = await gql(mutation);
+  const errors = res?.data?.productDeleteMedia?.mediaUserErrors;
+  if (errors && errors.length > 0) {
+    throw new Error(`Delete media error: ${errors.map(e => e.message).join(', ')}`);
+  }
+  return res?.data?.productDeleteMedia?.deletedMediaIds || [];
+}
+
+async function main() {
+  log('=== Nina Campbell Image Cropper ===');
+  log('Fetching all Nina Campbell products...');
+
+  const products = await fetchAllProducts();
+  log(`Total products found: ${products.length}`);
+
+  let cropped = 0, skippedSquare = 0, skippedNoImage = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const product = products[i];
+    const shortTitle = product.title.slice(0, 50);
+
+    // Get the first MediaImage
+    const mediaEdges = product.media?.edges || [];
+    const firstImage = mediaEdges.find(e => e.node.image);
+    if (!firstImage) {
+      log(`  [${i + 1}/${products.length}] ${shortTitle} — NO IMAGE, skip`);
+      skippedNoImage++;
+      continue;
+    }
+
+    const imgNode = firstImage.node;
+    const imgUrl = imgNode.image.url;
+    const origW = imgNode.image.width;
+    const origH = imgNode.image.height;
+    const oldMediaId = imgNode.id;
+
+    // Check if already square
+    const ratio = origW / origH;
+    if (ratio >= 0.9 && ratio <= 1.1) {
+      log(`  [${i + 1}/${products.length}] ${shortTitle} — already square (${origW}x${origH}), skip`);
+      skippedSquare++;
+      continue;
+    }
+
+    // Check it's taller than wide (expected ~0.66 ratio)
+    if (origW >= origH) {
+      log(`  [${i + 1}/${products.length}] ${shortTitle} — wider than tall (${origW}x${origH}), skip`);
+      skippedSquare++;
+      continue;
+    }
+
+    const inputFile = path.join(TMP_DIR, `input_${i}.jpg`);
+    const outputFile = path.join(TMP_DIR, `output_${i}.jpg`);
+
+    try {
+      // 1. Download
+      await download(imgUrl, inputFile);
+
+      // 2. Verify dimensions with ImageMagick
+      const dims = getImageDimensions(inputFile);
+      const localRatio = dims.width / dims.height;
+      if (localRatio >= 0.9 && localRatio <= 1.1) {
+        log(`  [${i + 1}/${products.length}] ${shortTitle} — measured square (${dims.width}x${dims.height}), skip`);
+        skippedSquare++;
+        continue;
+      }
+
+      // 3. Crop to square from top
+      cropToSquare(inputFile, outputFile, dims.width);
+      log(`  [${i + 1}/${products.length}] ${shortTitle} — cropped ${dims.width}x${dims.height} → ${dims.width}x${dims.width}`);
+
+      // 4. Staged upload
+      const ext = imgUrl.includes('.png') ? '.png' : '.jpg';
+      const filename = `nina_crop_${product.id.split('/').pop()}${ext}`;
+      const resourceUrl = await stagedUpload(outputFile, filename);
+      await sleep(500);
+
+      // 5. Create media on product
+      const altText = product.title.split('|')[0].trim();
+      const newMediaId = await createMedia(product.id, resourceUrl, altText);
+      log(`    Created new media: ${newMediaId}`);
+
+      // 6. Wait for media to be READY
+      const ready = await waitForMediaReady(product.id, newMediaId, 45000);
+      if (!ready) {
+        log(`    WARNING: Media not ready after 45s, proceeding anyway`);
+      }
+
+      // 7. Fetch current media list to build reorder array
+      const mediaQuery = `{
+        product(id: "${product.id}") {
+          media(first: 20) {
+            edges {
+              node {
+                ... on MediaImage { id }
+              }
+            }
+          }
+        }
+      }`;
+      const mediaRes = await gql(mediaQuery);
+      const currentMedia = (mediaRes?.data?.product?.media?.edges || [])
+        .map(e => e.node.id)
+        .filter(id => id); // filter out nulls from non-MediaImage types
+
+      // Put new image first, then all others except old image
+      const reordered = [newMediaId, ...currentMedia.filter(id => id !== newMediaId && id !== oldMediaId)];
+      await reorderMedia(product.id, reordered);
+      log(`    Reordered: new image is now position 1`);
+
+      // 8. Delete old uncropped image
+      const deleted = await deleteMedia(product.id, [oldMediaId]);
+      log(`    Deleted old image: ${deleted.join(', ')}`);
+
+      cropped++;
+
+      // Cleanup tmp files
+      if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
+      if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
+
+    } catch (err) {
+      log(`  [${i + 1}/${products.length}] ${shortTitle} — ERROR: ${err.message}`);
+      errors++;
+      // Cleanup on error
+      if (fs.existsSync(inputFile)) try { fs.unlinkSync(inputFile); } catch (_) {}
+      if (fs.existsSync(outputFile)) try { fs.unlinkSync(outputFile); } catch (_) {}
+    }
+
+    // Rate limit: ~1.5 req/s average (each product does ~6 API calls)
+    await sleep(600);
+  }
+
+  log('');
+  log('=== SUMMARY ===');
+  log(`Total products: ${products.length}`);
+  log(`Cropped & updated: ${cropped}`);
+  log(`Skipped (already square): ${skippedSquare}`);
+  log(`Skipped (no image): ${skippedNoImage}`);
+  log(`Errors: ${errors}`);
+}
+
+main().catch((e) => {
+  log(`FATAL: ${e.message}`);
+  console.error(e);
+  process.exit(1);
+});
diff --git a/nina_full_monty.js b/nina_full_monty.js
new file mode 100644
index 0000000..aab57ef
--- /dev/null
+++ b/nina_full_monty.js
@@ -0,0 +1,754 @@
+#!/usr/bin/env node
+/**
+ * Nina Campbell Full Monty — ALL ~759 products
+ * NO image cropping. NO room settings. NO SKU changes.
+ *
+ * Pipeline per product:
+ *   1. MFR SKU — verify/extract from image filename if missing
+ *   2. Pattern + Color — match catalog OR Gemini color analysis
+ *   3. Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
+ *   4. Body HTML — exactly 2 paragraphs (editorial + AI description)
+ *   5. Metafields — brand, width, material, unit_of_measure, packaging, full_monty_date, pattern_name, color_name
+ *   6. Migrate global.* → custom.* (width, repeat, fire_rating, Collection, Brand, etc.)
+ *   7. Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
+ *   8. Tags — remove "Color:" prefix, add "Class A Fire Rated"
+ *   9. NO image cropping. NO room settings. NO SKU changes.
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const { Client } = require('pg');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const API_VER = '2024-10';
+const FULL_MONTY_DATE = '2026-04-07';
+const LOG_FILE = '/tmp/nina_monty.log';
+const PG_URL = process.env.DATABASE_URL;
+const PAGE_SIZE = 25;
+const API_DELAY = 500;  // ms between Shopify API calls
+const GEMINI_DELAY = 1200; // ms between Gemini calls
+
+if (!TOKEN || !GEMINI_KEY || !PG_URL) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN, GEMINI_API_KEY, and DATABASE_URL environment variables are required');
+}
+
+// Initialize log
+fs.writeFileSync(LOG_FILE, `=== Nina Campbell Full Monty ===\nStarted: ${new Date().toISOString()}\n\n`);
+
+function log(msg) {
+  const line = `[${new Date().toISOString()}] ${msg}`;
+  console.log(line);
+  fs.appendFileSync(LOG_FILE, line + '\n');
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---- GraphQL helpers ----
+
+function gqlRaw(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE,
+      path: `/admin/api/${API_VER}/graphql.json`,
+      method: 'POST',
+      headers: {
+        'X-Shopify-Access-Token': TOKEN,
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(data),
+      },
+    }, res => {
+      let c = '';
+      res.on('data', d => c += d);
+      res.on('end', () => {
+        try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); }
+      });
+    });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data);
+    req.end();
+  });
+}
+
+async function gql(body, retries = 3) {
+  for (let attempt = 1; attempt <= retries; attempt++) {
+    try {
+      const result = await gqlRaw(body);
+      // Check for throttling
+      if (result?.errors?.[0]?.message?.includes('Throttled')) {
+        log(`  GQL throttled, waiting 3s (attempt ${attempt}/${retries})`);
+        await sleep(3000);
+        continue;
+      }
+      return result;
+    } catch (e) {
+      if (attempt < retries) {
+        log(`  GQL error: ${e.message}, retrying in ${attempt * 2}s`);
+        await sleep(attempt * 2000);
+        continue;
+      }
+      throw e;
+    }
+  }
+}
+
+// ---- Image download ----
+
+function downloadImage(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close();
+        return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      if (res.statusCode !== 200) {
+        file.close();
+        return reject(new Error(`HTTP ${res.statusCode}`));
+      }
+      res.pipe(file);
+      file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', e => { file.close(); reject(e); });
+  });
+}
+
+// ---- Gemini API ----
+
+function geminiCall(reqBody) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({
+      hostname: url.hostname,
+      path: url.pathname + url.search,
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => {
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => {
+        try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); }
+      });
+    });
+    req.on('error', () => resolve(null));
+    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
+    req.write(data);
+    req.end();
+  });
+}
+
+// ---- Title/text helpers ----
+
+function toTitleCase(s) {
+  if (!s) return '';
+  return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
+}
+
+// Clean pattern name: remove "(Wa3)" suffix, clean up collection prefix junk
+function cleanPatternName(raw) {
+  if (!raw) return '';
+  let name = raw
+    .replace(/\s*\(Wa\d+\)\s*/gi, '')          // Remove (Wa3) etc
+    .replace(/\s*Wallaper\b/gi, '')             // Typo in catalog "Wallaper"
+    .replace(/\s*Wallpaper\b/gi, '')
+    .replace(/\s*Wallcovering\b/gi, '')
+    .trim();
+
+  // If pattern has collection prefix like "Les Reves Collioure Blue/Beige"
+  // and the color portion is after the last space-slash, we want to keep the whole pattern
+  // but strip color-like suffixes that belong in color_name
+  // e.g. "Fontibre Kershaw Plain Black/Ivory" -> pattern = "Fontibre Kershaw Plain"
+  // But we keep it all — the color_name field handles the color separately
+  // Just strip trailing color descriptions after the last known pattern word
+  // Actually: keep the full pattern_name as-is from catalog. Color comes from color_name field.
+
+  return name;
+}
+
+// Extract NCW/NCF code from image filename or URL
+function extractMfrSkuFromUrl(url) {
+  if (!url) return null;
+  // Match NCW####-## or NCF####-## pattern
+  const match = url.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
+  return match ? match[1].toUpperCase() : null;
+}
+
+// ---- Global → Custom field mapping ----
+
+function cleanWidth(v) {
+  if (!v) return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+function cleanRepeat(v) {
+  if (!v || v === 'N/A' || v === '0' || v.toLowerCase() === 'random' || v.toLowerCase() === 'none') return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+
+function cleanFireRating(v) {
+  if (!v) return null;
+  let f = v.replace(/["']/g, '').trim();
+  if (f.length > 100) return null;
+  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
+  return f || null;
+}
+
+function cleanMaterial(v) {
+  if (!v) return null;
+  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
+}
+
+const FIELD_MAP = {
+  'width':              { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'Width':              { key: 'width', clean: cleanWidth, type: 'single_line_text_field' },
+  'repeat':             { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Rpt':           { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Vert-Repeat':        { key: 'pattern_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Horiz-Rpt':          { key: 'horizontal_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'Horiz-Repeat':       { key: 'horizontal_repeat', clean: cleanRepeat, type: 'single_line_text_field' },
+  'fire_rating':        { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'FLAMMABILITY':       { key: 'fire_rating', clean: cleanFireRating, type: 'single_line_text_field' },
+  'Contents':           { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Content':            { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Construction':       { key: 'material', clean: cleanMaterial, type: 'multi_line_text_field' },
+  'Collection':         { key: 'collection_name', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Brand':              { key: 'brand', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'MATCH':              { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Match':              { key: 'match_type', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Finish':             { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'FINISH':             { key: 'finish', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Cleaning':           { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Clean-Code':         { key: 'care', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'application':        { key: 'application', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Substrate':          { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'substrate':          { key: 'backing', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'length':             { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Length':             { key: 'length', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'packaged':           { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Packaged':           { key: 'packaging', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'unit_of_measure':    { key: 'unit_of_measure', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Weight':             { key: 'product_weight', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country':            { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Country-of-Origin':  { key: 'origin', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'color':              { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+  'Color-Way':          { key: 'color', clean: v => v?.trim() || null, type: 'single_line_text_field' },
+};
+
+// ---- Load catalog from PostgreSQL ----
+
+async function loadCatalog() {
+  const client = new Client({ connectionString: PG_URL });
+  await client.connect();
+  const res = await client.query('SELECT mfr_sku, pattern_name, color_name, collection FROM nina_campbell_catalog');
+  await client.end();
+
+  const byMfr = {};
+  for (const row of res.rows) {
+    if (row.mfr_sku) {
+      byMfr[row.mfr_sku.toUpperCase()] = row;
+    }
+  }
+  log(`Loaded ${res.rows.length} catalog entries from nina_campbell_catalog`);
+  return byMfr;
+}
+
+// ---- Fetch all products ----
+
+async function fetchAllProducts() {
+  log('Fetching all Nina Campbell active products...');
+  const allProducts = [];
+  let cursor = null;
+
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const query = `{
+      products(first: ${PAGE_SIZE}, query: "vendor:\\"Nina Campbell\\" AND status:active"${after}) {
+        edges {
+          cursor
+          node {
+            id
+            title
+            descriptionHtml
+            vendor
+            tags
+            images(first: 10) {
+              edges { node { id url altText } }
+            }
+            media(first: 10) {
+              edges { node { ... on MediaImage { id image { url altText } } } }
+            }
+            variants(first: 5) {
+              edges { node { id sku title } }
+            }
+            metafields(first: 50) {
+              edges { node { namespace key value type } }
+            }
+          }
+        }
+        pageInfo { hasNextPage }
+      }
+    }`;
+
+    const r = await gql({ query });
+
+    if (r?.errors?.length) {
+      log(`  GQL top-level error: ${JSON.stringify(r.errors[0]).slice(0, 200)}`);
+    }
+
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+
+    for (const edge of edges) {
+      allProducts.push(edge.node);
+      cursor = edge.cursor;
+    }
+
+    log(`  Fetched ${allProducts.length} products so far...`);
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(API_DELAY);
+  }
+
+  log(`Total products fetched: ${allProducts.length}\n`);
+  return allProducts;
+}
+
+// ---- Process a single product ----
+
+async function processProduct(p, idx, total, catalog) {
+  const pid = p.id;
+  const origTitle = p.title;
+  log(`\n[${idx + 1}/${total}] ${origTitle}`);
+
+  // Collect metafields by namespace
+  const globals = {};
+  const customs = {};
+  for (const m of (p.metafields?.edges || [])) {
+    const { namespace, key, value } = m.node;
+    if (namespace === 'global' && value) globals[key] = value;
+    if (namespace === 'custom' && value) customs[key] = value;
+  }
+
+  // Get primary DW SKU from first non-sample variant
+  const variants = p.variants?.edges || [];
+  let dwSku = '';
+  for (const v of variants) {
+    const s = v.node.sku || '';
+    if (!s.toLowerCase().endsWith('-sample') && s) { dwSku = s; break; }
+  }
+  if (!dwSku && variants.length > 0) dwSku = variants[0].node.sku || '';
+
+  const result = { specs: 0, color: false, body: false, title: false, montyDate: false, alt: 0, tags: false, errors: [] };
+
+  // ============================================================
+  // STEP 1: MFR SKU — verify or extract from image filename
+  // ============================================================
+  let mfrSku = (customs['manufacturer_sku'] || '').toUpperCase().trim();
+
+  if (!mfrSku) {
+    // Try to extract NCW/NCF code from image URLs
+    for (const img of (p.images?.edges || [])) {
+      const extracted = extractMfrSkuFromUrl(img.node.url);
+      if (extracted) {
+        mfrSku = extracted;
+        log(`  Step 1 MFR SKU: extracted from image filename: ${mfrSku}`);
+        break;
+      }
+    }
+    // Also try title
+    if (!mfrSku) {
+      const titleMatch = origTitle.match(/(NC[WF]\d{3,5}-\d{1,2})/i);
+      if (titleMatch) mfrSku = titleMatch[1].toUpperCase();
+    }
+  }
+
+  // ============================================================
+  // STEP 2: Pattern + Color — catalog match or Gemini
+  // ============================================================
+  let patternName = customs['pattern_name'] || '';
+  let colorName = customs['color_name'] || '';
+  let catalogMatch = false;
+
+  // Check catalog
+  const catEntry = mfrSku ? catalog[mfrSku] : null;
+  if (catEntry) {
+    const rawPattern = cleanPatternName(catEntry.pattern_name);
+    // Only update if current pattern is bad (like "Nina Campbell s") or empty
+    if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
+      patternName = rawPattern;
+    }
+    if (!colorName || colorName.length < 2) {
+      colorName = toTitleCase(catEntry.color_name);
+    }
+    catalogMatch = true;
+    log(`  Step 2 Catalog match: ${mfrSku} -> "${patternName}" / "${colorName}"`);
+  }
+
+  // If no color, use Gemini on the image
+  const imgUrl = p.images?.edges?.[0]?.node?.url;
+  let aiDesc = '';
+  let needGeminiColor = !colorName && imgUrl;
+  let needGeminiDesc = true; // Always get AI description for body
+
+  if (needGeminiColor || needGeminiDesc) {
+    if (imgUrl) {
+      try {
+        const tmpImg = '/tmp/nina_monty_img.jpg';
+        await downloadImage(imgUrl, tmpImg);
+        const imgB64 = fs.readFileSync(tmpImg).toString('base64');
+
+        // Combined call: get color (if needed) + description
+        if (needGeminiColor) {
+          const colorResult = await geminiCall({
+            contents: [{ parts: [
+              { text: 'What is the dominant color of this wallcovering? Use designer terms. Reply 1-2 words only.' },
+              { inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
+            ]}],
+            generationConfig: { temperature: 0.2, maxOutputTokens: 50 },
+          });
+          const colorText = (colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
+          if (colorText && colorText.length < 30) {
+            colorName = toTitleCase(colorText.replace(/[.\n]/g, '').trim());
+            log(`  Step 2 Gemini color: "${colorName}"`);
+          }
+          await sleep(GEMINI_DELAY);
+        }
+
+        // Get AI description (1 sentence about texture/pattern)
+        const displayName = patternName || origTitle.split('|')[0].replace(/wallcovering/gi, '').trim();
+        const descResult = await geminiCall({
+          contents: [{ parts: [
+            { text: `Describe this wallcovering in exactly 1 sentence for a design professional. Focus on texture, pattern, and visual effect. Do NOT use the word "wallpaper" — always say "wallcovering". Do NOT start with the product name. Do NOT mention the brand. Maximum 30 words.` },
+            { inlineData: { mimeType: 'image/jpeg', data: imgB64 } },
+          ]}],
+          generationConfig: { temperature: 0.3, maxOutputTokens: 100 },
+        });
+        aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '')
+          .replace(/wallpaper/gi, 'wallcovering')
+          .replace(/\n/g, ' ')
+          .trim();
+        await sleep(GEMINI_DELAY);
+
+        // Clean up temp file
+        try { fs.unlinkSync(tmpImg); } catch {}
+      } catch (e) {
+        result.errors.push(`Gemini: ${(e.message || '').slice(0, 60)}`);
+        log(`  Step 2 Gemini ERROR: ${(e.message || '').slice(0, 60)}`);
+      }
+    }
+  }
+
+  // If pattern still bad, try parsing from title
+  if (!patternName || patternName === 'Nina Campbell s' || patternName.length < 3) {
+    // Extract from title: "Something - Color Wallcovering | Nina Campbell"
+    let cleaned = origTitle.replace(/\s*\|\s*Nina Campbell\s*(Europe)?\s*$/i, '').trim();
+    cleaned = cleaned.replace(/\s*Wallcovering\s*$/i, '').trim();
+    const dashIdx = cleaned.lastIndexOf(' - ');
+    if (dashIdx > 0) {
+      patternName = cleaned.substring(0, dashIdx).trim();
+    } else {
+      patternName = cleaned;
+    }
+  }
+
+  // Fallback color
+  if (!colorName) {
+    colorName = 'Natural';
+  }
+
+  // ============================================================
+  // STEP 3: Title — "{Pattern} - {Color} Wallcovering | Nina Campbell"
+  // ============================================================
+  const newTitle = `${patternName} - ${colorName} Wallcovering | Nina Campbell`;
+  if (newTitle !== origTitle) {
+    try {
+      const r = await gql({
+        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+        variables: { i: { id: pid, title: newTitle } },
+      });
+      const errs = r?.data?.productUpdate?.userErrors || [];
+      if (errs.length > 0) {
+        result.errors.push(`Title: ${errs[0].message}`);
+      } else {
+        result.title = true;
+        log(`  Step 3 Title: "${origTitle}" -> "${newTitle}"`);
+      }
+    } catch (e) {
+      result.errors.push(`Title: ${(e.message || '').slice(0, 40)}`);
+    }
+    await sleep(API_DELAY);
+  } else {
+    result.title = true;
+    log(`  Step 3 Title: already correct`);
+  }
+
+  // ============================================================
+  // STEP 4: Body HTML — exactly 2 paragraphs
+  // ============================================================
+  const p1 = `${patternName} in ${colorName} is a refined wallcovering by Nina Campbell. This design brings elegant sophistication to residential and commercial interiors.`;
+  const p2 = aiDesc || 'Featuring a carefully curated palette and understated texture, this wallcovering delivers timeless appeal suitable for discerning design applications.';
+  const newBody = `<p>${p1}</p>\n<p>${p2}</p>`;
+
+  try {
+    const r = await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: pid, descriptionHtml: newBody } },
+    });
+    const errs = r?.data?.productUpdate?.userErrors || [];
+    if (errs.length > 0) {
+      result.errors.push(`Body: ${errs[0].message}`);
+    } else {
+      result.body = true;
+      log(`  Step 4 Body: 2 paragraphs set`);
+    }
+  } catch (e) {
+    result.errors.push(`Body: ${(e.message || '').slice(0, 40)}`);
+  }
+  await sleep(API_DELAY);
+
+  // ============================================================
+  // STEP 5: Metafields — required fields + migrate global.* -> custom.*
+  // ============================================================
+  const mfToSet = [];
+  const seen = new Set();
+
+  // Required metafields (always set/overwrite)
+  const requiredMf = [
+    { key: 'pattern_name', value: patternName, type: 'single_line_text_field' },
+    { key: 'color_name', value: colorName, type: 'single_line_text_field' },
+    { key: 'brand', value: 'Nina Campbell', type: 'single_line_text_field' },
+    { key: 'width', value: '20.5 Inches', type: 'single_line_text_field' },
+    { key: 'material', value: 'Wallcovering', type: 'multi_line_text_field' },
+    { key: 'unit_of_measure', value: 'Priced Per Single Roll', type: 'single_line_text_field' },
+    { key: 'packaging', value: 'Double Roll', type: 'single_line_text_field' },
+    { key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' },
+  ];
+
+  // Set MFR SKU if we found one and it's not already set
+  if (mfrSku && !customs['manufacturer_sku']) {
+    requiredMf.push({ key: 'manufacturer_sku', value: mfrSku, type: 'single_line_text_field' });
+  }
+
+  for (const mf of requiredMf) {
+    mfToSet.push({
+      ownerId: pid, namespace: 'custom', key: mf.key,
+      value: mf.value, type: mf.type,
+    });
+    seen.add(mf.key);
+  }
+
+  // Migrate global.* -> custom.* for unmapped fields
+  for (const [gKey, mapping] of Object.entries(FIELD_MAP)) {
+    if (!globals[gKey]) continue;
+    if (seen.has(mapping.key)) continue; // Already set by required fields
+    if (customs[mapping.key]) continue;  // Already has custom value
+
+    const cleaned = mapping.clean(globals[gKey]);
+    if (!cleaned) continue;
+
+    mfToSet.push({
+      ownerId: pid, namespace: 'custom', key: mapping.key,
+      value: cleaned, type: mapping.type,
+    });
+    seen.add(mapping.key);
+  }
+
+  if (mfToSet.length > 0) {
+    try {
+      const r = await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: mfToSet },
+      });
+      const errs = r?.data?.metafieldsSet?.userErrors || [];
+      if (errs.length > 0) {
+        result.errors.push(`Metafields: ${errs.map(e => e.message).join('; ').slice(0, 100)}`);
+        log(`  Step 5 Metafields: ERRORS ${errs[0].message}`);
+      } else {
+        result.specs = mfToSet.length;
+        log(`  Step 5 Metafields: ${mfToSet.length} fields set`);
+      }
+    } catch (e) {
+      result.errors.push(`Metafields: ${(e.message || '').slice(0, 40)}`);
+    }
+    await sleep(API_DELAY);
+  }
+
+  // ============================================================
+  // STEP 7: Image alt tags — "{DW SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
+  // ============================================================
+  const cleanSku = dwSku.replace(/-[Ss]ample$/, '').trim();
+  const altBase = `${cleanSku} ${patternName} ${colorName} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
+  const mediaEdges = p.media?.edges || [];
+
+  if (mediaEdges.length > 0) {
+    const mediaUpdates = [];
+    for (let mi = 0; mi < mediaEdges.length; mi++) {
+      const mediaNode = mediaEdges[mi].node;
+      if (!mediaNode?.id) continue;
+      const altTag = mi === 0 ? altBase : `${altBase} ${mi + 1}`;
+      mediaUpdates.push({ id: mediaNode.id, alt: altTag });
+    }
+
+    if (mediaUpdates.length > 0) {
+      try {
+        const mediaJson = mediaUpdates
+          .map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`)
+          .join(', ');
+        const r = await gql({
+          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }`,
+        });
+        const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
+        if (errs.length > 0) {
+          result.errors.push(`Alt: ${errs[0].message}`);
+        } else {
+          result.alt = mediaUpdates.length;
+          log(`  Step 7 Alt tags: ${mediaUpdates.length} images updated`);
+        }
+      } catch (e) {
+        result.errors.push(`Alt: ${(e.message || '').slice(0, 40)}`);
+      }
+      await sleep(API_DELAY);
+    }
+  } else {
+    log(`  Step 7 Alt tags: no media found`);
+  }
+
+  // ============================================================
+  // STEP 8: Tags — remove "Color:" prefix, add "Class A Fire Rated", add MFR SKU as tag
+  // ============================================================
+  const currentTags = p.tags || [];
+  let newTags = [...currentTags];
+  let tagsChanged = false;
+
+  // Remove "Color:" prefix from tags
+  for (let ti = 0; ti < newTags.length; ti++) {
+    if (newTags[ti].startsWith('Color:')) {
+      newTags[ti] = newTags[ti].replace(/^Color:\s*/, '').trim();
+      tagsChanged = true;
+    }
+  }
+
+  // Add "Class A Fire Rated" if not present
+  if (!newTags.some(t => t.toLowerCase() === 'class a fire rated')) {
+    newTags.push('Class A Fire Rated');
+    tagsChanged = true;
+  }
+
+  // Add MFR SKU as tag if not present
+  if (mfrSku && !newTags.some(t => t.toUpperCase() === mfrSku)) {
+    newTags.push(mfrSku);
+    tagsChanged = true;
+  }
+
+  // Remove duplicates (case-insensitive)
+  const tagSet = new Map();
+  for (const t of newTags) {
+    const lower = t.toLowerCase().trim();
+    if (lower && !tagSet.has(lower)) tagSet.set(lower, t.trim());
+  }
+  newTags = Array.from(tagSet.values());
+
+  if (tagsChanged) {
+    try {
+      const tagStr = newTags.join(', ');
+      const r = await gql({
+        query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+        variables: { i: { id: pid, tags: newTags } },
+      });
+      const errs = r?.data?.productUpdate?.userErrors || [];
+      if (errs.length > 0) {
+        result.errors.push(`Tags: ${errs[0].message}`);
+      } else {
+        result.tags = true;
+        log(`  Step 8 Tags: updated (${newTags.length} tags)`);
+      }
+    } catch (e) {
+      result.errors.push(`Tags: ${(e.message || '').slice(0, 40)}`);
+    }
+    await sleep(API_DELAY);
+  } else {
+    result.tags = true;
+    log(`  Step 8 Tags: already correct`);
+  }
+
+  if (result.errors.length > 0) {
+    log(`  ERRORS: ${result.errors.join(' | ')}`);
+  }
+
+  return result;
+}
+
+// ---- Main ----
+
+async function main() {
+  log('=== Nina Campbell Full Monty (NO Room Settings, NO Image Cropping, NO SKU Changes) ===');
+  log(`Date: ${FULL_MONTY_DATE}`);
+  log(`Store: ${STORE}`);
+  log(`Page size: ${PAGE_SIZE}`);
+  log(`API delay: ${API_DELAY}ms | Gemini delay: ${GEMINI_DELAY}ms\n`);
+
+  // Load catalog from PostgreSQL
+  const catalog = await loadCatalog();
+
+  // Fetch all products
+  const products = await fetchAllProducts();
+
+  const stats = {
+    total: products.length,
+    catalogMatches: 0,
+    geminiColors: 0,
+    specsFieldsSet: 0,
+    bodiesRewritten: 0,
+    titlesUpdated: 0,
+    montyDatesSet: 0,
+    altTagsUpdated: 0,
+    tagsUpdated: 0,
+    errors: 0,
+  };
+
+  for (let i = 0; i < products.length; i++) {
+    try {
+      const result = await processProduct(products[i], i, products.length, catalog);
+      stats.specsFieldsSet += result.specs;
+      if (result.color) stats.geminiColors++;
+      if (result.body) stats.bodiesRewritten++;
+      if (result.title) stats.titlesUpdated++;
+      stats.altTagsUpdated += result.alt;
+      if (result.tags) stats.tagsUpdated++;
+      stats.errors += result.errors.length;
+    } catch (e) {
+      log(`  FATAL ERROR on product ${i + 1}: ${e.message}`);
+      stats.errors++;
+    }
+
+    // Progress every 25
+    if ((i + 1) % 25 === 0 || i === products.length - 1) {
+      log(`\n--- PROGRESS: ${i + 1}/${products.length} (${((i + 1) / products.length * 100).toFixed(1)}%) ---`);
+      log(`  Specs: ${stats.specsFieldsSet} | Bodies: ${stats.bodiesRewritten} | Titles: ${stats.titlesUpdated} | Alt: ${stats.altTagsUpdated} | Tags: ${stats.tagsUpdated} | Errors: ${stats.errors}\n`);
+    }
+  }
+
+  const summary = `
+=== NINA CAMPBELL FULL MONTY COMPLETE ===
+Total products: ${stats.total}
+Specs/metafields set: ${stats.specsFieldsSet}
+Bodies rewritten: ${stats.bodiesRewritten}
+Titles updated: ${stats.titlesUpdated}
+Alt tags updated: ${stats.altTagsUpdated}
+Tags updated: ${stats.tagsUpdated}
+Errors: ${stats.errors}
+Finished: ${new Date().toISOString()}
+`;
+  log(summary);
+}
+
+main().catch(e => {
+  log(`FATAL: ${e.message}\n${e.stack}`);
+  process.exit(1);
+});
diff --git a/nina_names_colors.js b/nina_names_colors.js
new file mode 100644
index 0000000..163a297
--- /dev/null
+++ b/nina_names_colors.js
@@ -0,0 +1,158 @@
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+const catalog = JSON.parse(fs.readFileSync('/tmp/nina_catalog.json'));
+const byMfr = {};
+for (const c of catalog) { if (c.mfr_sku) byMfr[c.mfr_sku.toUpperCase()] = c; }
+console.log(`Loaded ${catalog.length} catalog entries\n`);
+
+function gql(body) {
+  return new Promise((res, rej) => {
+    const data = JSON.stringify(body);
+    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
+    req.on('error', rej); req.write(data); req.end();
+  });
+}
+function gemini(reqBody) {
+  return new Promise((res) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + GEMINI_KEY);
+    const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
+    req.on('error', ()=>res(null)); req.write(data); req.end();
+  });
+}
+function download(url, dest) {
+  return new Promise((res, rej) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, {headers:{'User-Agent':'Mozilla/5.0'}}, r => {
+      if(r.statusCode>=300&&r.headers.location){file.close();return download(r.headers.location,dest).then(res).catch(rej)}
+      r.pipe(file); file.on('finish',()=>{file.close();res()});
+    }).on('error',e=>{file.close();rej(e)});
+  });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function toTitleCase(s) {
+  if (!s) return '';
+  return s.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.substr(1).toLowerCase()).trim();
+}
+
+async function main() {
+  console.log('=== Nina Campbell: Real Names + Colors ===\n');
+  let cursor = null;
+  const products = [];
+  while (true) {
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({ query: `{ products(first: 50, query: "vendor:\\"Nina Campbell\\" AND status:active"${after}) { edges { cursor node { id title images(first:1) { edges { node { url } } } metafields(first: 3, keys:["custom.manufacturer_sku","custom.color_name"]) { edges { node { key value } } } } } pageInfo { hasNextPage } } }` });
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+    for (const e of edges) { products.push(e.node); cursor = e.cursor; }
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(500);
+  }
+  console.log(`${products.length} products\n`);
+
+  const usedNames = {};
+  let catalogMatch = 0, aiColor = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const metas = {};
+    for (const m of p.metafields.edges) metas[m.node.key] = m.node.value;
+    const mfrSku = (metas.manufacturer_sku || '').toUpperCase();
+
+    let patternName = '';
+    let colorName = '';
+
+    // Try catalog match
+    const match = byMfr[mfrSku];
+    if (match) {
+      // Extract clean pattern name (remove collection prefix like "Les Rêves ")
+      const pn = match.pattern_name || '';
+      // Pattern name often includes collection: "Fontibre Kershaw Plain Black/Ivory"
+      // Keep it as-is but use the color_name field
+      patternName = pn.split(/\s+[-–]\s+/)[0].trim() || pn;
+      colorName = toTitleCase(match.color_name);
+      catalogMatch++;
+    }
+
+    // If no color from catalog, use AI on the image
+    if (!colorName) {
+      const imgUrl = p.images?.edges?.[0]?.node?.url;
+      if (imgUrl) {
+        try {
+          const tmp = '/tmp/nina_color.jpg';
+          await download(imgUrl, tmp);
+          const b64 = fs.readFileSync(tmp).toString('base64');
+
+          // Get pattern base from existing title
+          if (!patternName) {
+            patternName = p.title.split('|')[0].replace(/wallcovering/gi,'').trim();
+          }
+          if (!usedNames[patternName]) usedNames[patternName] = new Set();
+          const used = [...usedNames[patternName]].join(', ');
+
+          const cr = await gemini({
+            contents: [{ parts: [
+              { text: 'You are an interior designer. What is the dominant color of this wallcovering? Use specific designer terms (Ivory, Charcoal, Sage, Blush, Cobalt, etc.).' + (used ? ' NOT: ' + used + '.' : '') + ' Reply with ONLY 1-2 words.' },
+              { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+            ]}],
+            generationConfig: { temperature: 0.4, maxOutputTokens: 15 }
+          });
+          colorName = (cr?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim().replace(/[\"'\n]/g, '');
+          aiColor++;
+        } catch { errors++; }
+        await sleep(1200);
+      }
+    }
+
+    if (!colorName) colorName = '';
+    if (!patternName) patternName = p.title.split('|')[0].trim();
+
+    // Track used names
+    if (!usedNames[patternName]) usedNames[patternName] = new Set();
+    if (colorName) usedNames[patternName].add(colorName);
+
+    // Build title
+    const newTitle = colorName 
+      ? `${patternName} - ${colorName} Wallcovering | Nina Campbell`
+      : `${patternName} Wallcovering | Nina Campbell`;
+
+    // Update title + color metafield
+    await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: p.id, title: newTitle } }
+    });
+    if (colorName) {
+      await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: [
+          { ownerId: p.id, namespace: 'custom', key: 'color_name', value: colorName, type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'pattern_name', value: patternName, type: 'single_line_text_field' },
+        ]}
+      });
+    }
+
+    if ((i+1) % 25 === 0) console.log(`[${i+1}/${products.length}] catalog:${catalogMatch} ai:${aiColor} errors:${errors}`);
+    await sleep(500);
+  }
+
+  console.log(`\n=== COMPLETE ===`);
+  console.log(`Catalog matches: ${catalogMatch} | AI colors: ${aiColor} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..d8b2647
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2326 @@
+{
+  "name": "goodquestionralph",
+  "version": "0.2.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "goodquestionralph",
+      "version": "0.2.0",
+      "dependencies": {
+        "@anthropic-ai/sdk": "^0.39.0",
+        "clsx": "^2.1.1",
+        "framer-motion": "^12.26.1",
+        "lucide-react": "^0.562.0",
+        "next": "^16.0.3",
+        "pg": "^8.16.3",
+        "react": "^19.2.0",
+        "react-dom": "^19.2.0",
+        "react-resizable-panels": "^4.4.0",
+        "tailwind-merge": "^3.4.0"
+      },
+      "devDependencies": {
+        "@tailwindcss/postcss": "^4.0.0",
+        "@types/node": "^22.15.3",
+        "@types/pg": "^8.16.0",
+        "@types/react": "^19.1.6",
+        "@types/react-dom": "^19.1.5",
+        "postcss": "^8.5.3",
+        "tailwindcss": "^4.0.0",
+        "typescript": "^5.8.3"
+      }
+    },
+    "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/@anthropic-ai/sdk": {
+      "version": "0.39.0",
+      "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.39.0.tgz",
+      "integrity": "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      }
+    },
+    "node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "license": "MIT"
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
+      "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.1.tgz",
+      "integrity": "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.1.tgz",
+      "integrity": "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.1.tgz",
+      "integrity": "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.1.tgz",
+      "integrity": "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.1.tgz",
+      "integrity": "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.1.tgz",
+      "integrity": "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.1.tgz",
+      "integrity": "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "16.1.1",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.1.tgz",
+      "integrity": "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==",
+      "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.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+      "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.4",
+        "enhanced-resolve": "^5.18.3",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.30.2",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+      "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-x64": "4.1.18",
+        "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+        "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+      "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+      "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+      "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+      "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+      "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+      "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+      "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+      "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+      "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+      "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+      "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.7.1",
+        "@emnapi/runtime": "^1.7.1",
+        "@emnapi/wasi-threads": "^1.1.0",
+        "@napi-rs/wasm-runtime": "^1.1.0",
+        "@tybys/wasm-util": "^0.10.1",
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+      "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+      "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
+      "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.1.18",
+        "@tailwindcss/oxide": "4.1.18",
+        "postcss": "^8.4.41",
+        "tailwindcss": "4.1.18"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz",
+      "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.13",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.4"
+      }
+    },
+    "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==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.2.8",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
+      "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
+      "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/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "node_modules/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.9.14",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz",
+      "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001764",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz",
+      "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==",
+      "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/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "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/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.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/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.18.4",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+      "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
+    "node_modules/framer-motion": {
+      "version": "12.26.1",
+      "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.26.1.tgz",
+      "integrity": "sha512-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-dom": "^12.24.11",
+        "motion-utils": "^12.24.10",
+        "tslib": "^2.4.0"
+      },
+      "peerDependencies": {
+        "@emotion/is-prop-valid": "*",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/is-prop-valid": {
+          "optional": true
+        },
+        "react": {
+          "optional": true
+        },
+        "react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "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/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+      "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+      "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.30.2",
+        "lightningcss-darwin-arm64": "1.30.2",
+        "lightningcss-darwin-x64": "1.30.2",
+        "lightningcss-freebsd-x64": "1.30.2",
+        "lightningcss-linux-arm-gnueabihf": "1.30.2",
+        "lightningcss-linux-arm64-gnu": "1.30.2",
+        "lightningcss-linux-arm64-musl": "1.30.2",
+        "lightningcss-linux-x64-gnu": "1.30.2",
+        "lightningcss-linux-x64-musl": "1.30.2",
+        "lightningcss-win32-arm64-msvc": "1.30.2",
+        "lightningcss-win32-x64-msvc": "1.30.2"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+      "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+      "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+      "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+      "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+      "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+      "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+      "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+      "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+      "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+      "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+      "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.30.2",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+      "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+      "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.562.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
+      "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
+      "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/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/motion-dom": {
+      "version": "12.24.11",
+      "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.11.tgz",
+      "integrity": "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A==",
+      "license": "MIT",
+      "dependencies": {
+        "motion-utils": "^12.24.10"
+      }
+    },
+    "node_modules/motion-utils": {
+      "version": "12.24.10",
+      "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
+      "integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
+      "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "16.1.1",
+        "@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.1",
+        "@next/swc-darwin-x64": "16.1.1",
+        "@next/swc-linux-arm64-gnu": "16.1.1",
+        "@next/swc-linux-arm64-musl": "16.1.1",
+        "@next/swc-linux-x64-gnu": "16.1.1",
+        "@next/swc-linux-x64-musl": "16.1.1",
+        "@next/swc-win32-arm64-msvc": "16.1.1",
+        "@next/swc-win32-x64-msvc": "16.1.1",
+        "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/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg": {
+      "version": "8.16.3",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
+      "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.9.1",
+        "pg-pool": "^3.10.1",
+        "pg-protocol": "^1.10.3",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.2.7"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
+      "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.9.1",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
+      "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
+      "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.10.1",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
+      "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.10.3",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
+      "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
+      "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==",
+      "dev": true,
+      "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/react-resizable-panels": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.4.0.tgz",
+      "integrity": "sha512-vGH1rIhyDOL4RSWYTx3eatjDohDFIRxJCAXUOaeL9HyamptUnUezqndjMtBo9hQeaq1CIP0NBbc7ZV3lBtlgxA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      }
+    },
+    "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.3",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "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/tailwind-merge": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+      "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/dcastil"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+      "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+      "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/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
+    "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/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "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..9b361d3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+  "name": "goodquestionralph",
+  "version": "0.2.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev -p 7300",
+    "build": "next build",
+    "start": "next start -p 7300 -H 0.0.0.0",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "@anthropic-ai/sdk": "^0.39.0",
+    "clsx": "^2.1.1",
+    "framer-motion": "^12.26.1",
+    "lucide-react": "^0.562.0",
+    "next": "^16.0.3",
+    "pg": "^8.16.3",
+    "react": "^19.2.0",
+    "react-dom": "^19.2.0",
+    "react-resizable-panels": "^4.4.0",
+    "tailwind-merge": "^3.4.0"
+  },
+  "devDependencies": {
+    "@tailwindcss/postcss": "^4.0.0",
+    "@types/node": "^22.15.3",
+    "@types/pg": "^8.16.0",
+    "@types/react": "^19.1.6",
+    "@types/react-dom": "^19.1.5",
+    "postcss": "^8.5.3",
+    "tailwindcss": "^4.0.0",
+    "typescript": "^5.8.3"
+  }
+}
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/retro_full_monty.js b/retro_full_monty.js
new file mode 100644
index 0000000..bf87498
--- /dev/null
+++ b/retro_full_monty.js
@@ -0,0 +1,617 @@
+#!/usr/bin/env node
+/**
+ * Retro Walls Full Monty (NO room settings)
+ * ~1,052 products — vendor:"Retro Walls"
+ *
+ * Pipeline per product:
+ *   1. Migrate global.* / dwc.* metafields → custom.* (width, fire_rating, material, repeat, match_type, etc.)
+ *   2. AI color analysis via Gemini 2.0 Flash → color_hex, color_details, background_color, color_name
+ *   3. Body rewrite to exactly 2 paragraphs (no specs, no tables, no hashtags)
+ *   4. Title update: "Pattern - Color | Retro Walls"
+ *   5. Set custom.full_monty_date = "2026-04-06"
+ *   6. Image alt tags: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles"
+ *   7. NO room settings
+ *
+ * Resume-safe: tracks progress in /tmp/retro_monty_progress.json
+ * Logs to /tmp/retro_monty.log
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+const LOG_FILE = '/tmp/retro_monty.log';
+const PROGRESS_FILE = '/tmp/retro_monty_progress.json';
+const FULL_MONTY_DATE = '2026-04-06';
+
+// ---------- Logging ----------
+function log(msg) {
+  const ts = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
+  const line = `[${ts}] ${msg}`;
+  console.log(line);
+  fs.appendFileSync(LOG_FILE, line + '\n');
+}
+
+// ---------- Progress tracking (resume-safe) ----------
+function loadProgress() {
+  try { return JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf8')); }
+  catch { return { done: {}, cursor: null, page: 0, stats: { specs: 0, colors: 0, bodies: 0, titles: 0, alts: 0, fmDate: 0, errors: 0, skipped: 0 } }; }
+}
+function saveProgress(p) { fs.writeFileSync(PROGRESS_FILE, JSON.stringify(p, null, 2)); }
+
+// ---------- Shopify GraphQL ----------
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => {
+      let c = '';
+      res.on('data', d => c += d);
+      res.on('end', () => {
+        try {
+          const parsed = JSON.parse(c);
+          // Check for throttle
+          const available = parsed?.extensions?.cost?.throttleStatus?.currentlyAvailable;
+          if (available !== undefined && available < 200) {
+            // Throttled — wait and retry
+            log(`  THROTTLED (available: ${available}) — waiting 5s`);
+            setTimeout(() => gql(body).then(resolve).catch(reject), 5000);
+            return;
+          }
+          resolve(parsed);
+        } catch { resolve({ error: c.slice(0, 300) }); }
+      });
+    });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('gql timeout')); });
+    req.write(data); req.end();
+  });
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Image download ----------
+function downloadImage(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close();
+        return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      if (res.statusCode >= 400) {
+        file.close();
+        return reject(new Error(`HTTP ${res.statusCode}`));
+      }
+      res.pipe(file);
+      file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', e => { file.close(); reject(e); });
+  });
+}
+
+// ---------- Gemini API ----------
+function geminiCall(reqBody, retries = 2) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({
+      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, res => {
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => {
+        try {
+          const parsed = JSON.parse(Buffer.concat(chunks).toString());
+          if (parsed?.error && retries > 0) {
+            log(`  Gemini error: ${parsed.error.message?.slice(0, 80)} — retrying in 5s`);
+            setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 5000);
+            return;
+          }
+          resolve(parsed);
+        } catch { resolve(null); }
+      });
+    });
+    req.on('error', e => {
+      if (retries > 0) {
+        setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 3000);
+      } else resolve(null);
+    });
+    req.setTimeout(60000, () => {
+      req.destroy();
+      if (retries > 0) {
+        setTimeout(() => geminiCall(reqBody, retries - 1).then(resolve).catch(reject), 3000);
+      } else resolve(null);
+    });
+    req.write(data); req.end();
+  });
+}
+
+// ---------- Metafield migration mappings ----------
+const GLOBAL_TO_CUSTOM = {
+  'width':              { key: 'width',          type: 'single_line_text_field', clean: cleanWidth },
+  'Width':              { key: 'width',          type: 'single_line_text_field', clean: cleanWidth },
+  'repeat':             { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
+  'Vert-Rpt':           { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
+  'Vert-Repeat':        { key: 'pattern_repeat', type: 'single_line_text_field', clean: cleanRepeat },
+  'fire_rating':        { key: 'fire_rating',    type: 'single_line_text_field', clean: cleanFire },
+  'FLAMMABILITY':       { key: 'fire_rating',    type: 'single_line_text_field', clean: cleanFire },
+  'Contents':           { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
+  'Content':            { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
+  'Construction':       { key: 'material',       type: 'multi_line_text_field',  clean: cleanMaterial },
+  'Collection':         { key: 'collection_name', type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Brand':              { key: 'brand',          type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'MATCH':              { key: 'match_type',     type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Match':              { key: 'match_type',     type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Finish':             { key: 'finish',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'FINISH':             { key: 'finish',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Cleaning':           { key: 'care',           type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Clean-Code':         { key: 'care',           type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'application':        { key: 'application',    type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Substrate':          { key: 'backing',        type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'substrate':          { key: 'backing',        type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'length':             { key: 'length',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Length':             { key: 'length',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'packaged':           { key: 'packaging',      type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Packaged':           { key: 'packaging',      type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'unit_of_measure':    { key: 'unit_of_measure', type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Weight':             { key: 'product_weight', type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Country':            { key: 'origin',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Country-of-Origin':  { key: 'origin',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Style':              { key: 'style',          type: 'single_line_text_field', clean: v => v?.trim() || null },
+  // NOTE: custom.color is product_reference type in Shopify — do NOT write strings to it
+  // Color names go to custom.color_name instead
+  'color':              { key: 'color_name',     type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'Color-Way':          { key: 'color_name',     type: 'single_line_text_field', clean: v => v?.trim() || null },
+};
+
+// Also migrate dwc.* → custom.*
+const DWC_TO_CUSTOM = {
+  'pattern_name':  { key: 'pattern_name',  type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'brand':         { key: 'brand',         type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'collection':    { key: 'collection_name', type: 'single_line_text_field', clean: v => v?.trim() || null },
+  'width':         { key: 'width',         type: 'single_line_text_field', clean: cleanWidth },
+  'contents':      { key: 'material',      type: 'multi_line_text_field',  clean: cleanMaterial },
+  'fire_rating':   { key: 'fire_rating',   type: 'single_line_text_field', clean: cleanFire },
+};
+
+function cleanWidth(v) {
+  if (!v) return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+function cleanRepeat(v) {
+  if (!v || v === 'N/A' || v === '0') return null;
+  const m = v.match(/([\d.]+)/);
+  return m ? `${m[1]} Inches` : null;
+}
+function cleanFire(v) {
+  if (!v) return null;
+  let f = v.replace(/["']/g, '').trim();
+  if (f.length > 100) return null;
+  if (/class\s*a/i.test(f) && !f.includes('ASTM')) f = 'ASTM E-84 Class A';
+  return f || null;
+}
+function cleanMaterial(v) {
+  if (!v) return null;
+  return v.replace(/\bWallpaper\b/gi, 'Wallcovering').replace(/\bWallpapers\b/gi, 'Wallcoverings').trim() || null;
+}
+
+// ---------- Title helpers ----------
+function extractPatternAndColor(title) {
+  // Existing titles are varied:
+  //   "Black and White Paisley Wallcovering"
+  //   "Tropical Kitsch - 01 Pastel"
+  //   "Abstract Jars by ATA Designs- Ver.ATAD1028"
+  //   "Zepellin Zebras Grey, Black Traditional Wallcovering"
+  // Try to extract pattern & color from title or metafields
+  let clean = (title || '').trim();
+  // Remove trailing type words
+  clean = clean.replace(/\s*(Wallcovering|Wallpaper|Wall Paper|Wall Covering)\s*$/i, '').trim();
+  // Remove vendor attributions
+  clean = clean.replace(/\s*by\s+.*$/i, '').trim();
+  // Remove "DW " prefix if present
+  clean = clean.replace(/^DW\s+/i, '').trim();
+  // Remove " DW" suffix
+  clean = clean.replace(/\s+DW$/i, '').trim();
+  return clean;
+}
+
+// ---------- Fetch all products with pagination ----------
+async function fetchAllProducts(startCursor) {
+  const products = [];
+  let cursor = startCursor;
+  let page = 0;
+
+  while (true) {
+    page++;
+    const after = cursor ? `, after: "${cursor}"` : '';
+    const r = await gql({
+      query: `{ products(first: 25, query: "vendor:\\"Retro Walls\\""${after}, sortKey: CREATED_AT) {
+        edges { cursor node {
+          id title bodyHtml vendor productType tags
+          images(first: 10) { edges { node { id url altText } } }
+          media(first: 10) { edges { node { id alt mediaContentType ... on MediaImage { image { url } } } } }
+          variants(first: 5) { edges { node { id sku barcode title } } }
+          metafields(first: 50) { edges { node { namespace key value type } } }
+        } }
+        pageInfo { hasNextPage }
+      } }`
+    });
+
+    const edges = r?.data?.products?.edges || [];
+    if (edges.length === 0) break;
+
+    for (const edge of edges) {
+      products.push({ ...edge.node, _cursor: edge.cursor });
+    }
+
+    cursor = edges[edges.length - 1].cursor;
+
+    if (page % 5 === 0) {
+      log(`  Fetched ${products.length} products (page ${page})...`);
+    }
+
+    if (!r?.data?.products?.pageInfo?.hasNextPage) break;
+    await sleep(300);
+  }
+
+  return products;
+}
+
+// ---------- Main processing ----------
+async function processProduct(p, progress, idx, total) {
+  const pid = p.id;
+  const numericId = pid.replace('gid://shopify/Product/', '');
+
+  // Skip if already done
+  if (progress.done[numericId]) {
+    progress.stats.skipped++;
+    return;
+  }
+
+  log(`\n[${idx}/${total}] ${p.title}`);
+
+  // Collect existing metafields
+  const globals = {};
+  const customs = {};
+  const dwcs = {};
+  for (const m of (p.metafields?.edges || [])) {
+    const { namespace, key, value } = m.node;
+    if (namespace === 'global' && value) globals[key] = value;
+    if (namespace === 'custom' && value) customs[key] = value;
+    if (namespace === 'dwc' && value) dwcs[key] = value;
+  }
+
+  const sku = p.variants?.edges?.[0]?.node?.sku?.replace(/-sample$/i, '') || '';
+  const imgUrl = p.images?.edges?.[0]?.node?.url;
+  const allMfUpdates = [];
+
+  // ====== PHASE 1: Migrate global.* → custom.* ======
+  const seen = new Set(Object.keys(customs));
+
+  // global.* migrations
+  for (const [gKey, mapping] of Object.entries(GLOBAL_TO_CUSTOM)) {
+    if (!globals[gKey]) continue;
+    if (seen.has(mapping.key)) continue;
+    const cleaned = mapping.clean(globals[gKey]);
+    if (!cleaned) continue;
+    allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: mapping.key, value: cleaned, type: mapping.type });
+    seen.add(mapping.key);
+  }
+
+  // dwc.* migrations
+  for (const [dKey, mapping] of Object.entries(DWC_TO_CUSTOM)) {
+    if (!dwcs[dKey]) continue;
+    if (seen.has(mapping.key)) continue;
+    const cleaned = mapping.clean(dwcs[dKey]);
+    if (!cleaned) continue;
+    allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: mapping.key, value: cleaned, type: mapping.type });
+    seen.add(mapping.key);
+  }
+
+  if (allMfUpdates.length > 0) {
+    log(`  Phase 1 Specs: ${allMfUpdates.length} fields to migrate`);
+    progress.stats.specs++;
+  }
+
+  // ====== PHASE 2: AI Color Analysis via Gemini ======
+  let colorName = customs['color_name'] || customs['color'] || '';
+  let colorHex = customs['color_hex'] || '';
+  let colorData = null;
+
+  if (imgUrl && !customs['color_details']) {
+    try {
+      const tmpImg = `/tmp/retro_img_${numericId}.jpg`;
+      await downloadImage(imgUrl, tmpImg);
+      const imgB64 = fs.readFileSync(tmpImg).toString('base64');
+
+      const colorResult = await geminiCall({
+        contents: [{ parts: [
+          { text: 'Analyze this wallcovering image. Return ONLY valid JSON (no markdown, no code fences): {"dominantColor":"Color Name","dominantHex":"#XXXXXX","backgroundColor":"Color Name","backgroundHex":"#XXXXXX","colors":[{"name":"Color Name","hex":"#XXXXXX","percentage":40}]}. Use 3-6 colors summing to 100%. Use Title Case color names like "Navy Blue", "Warm Beige", "Forest Green".' },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+        ]}],
+        generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+      });
+
+      const text = colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+      const clean = text.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
+      colorData = JSON.parse(clean);
+
+      colorName = colorData.dominantColor || colorName;
+      colorHex = colorData.dominantHex || colorHex;
+
+      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_hex', value: colorData.dominantHex, type: 'single_line_text_field' });
+      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'background_color', value: colorData.backgroundColor, type: 'single_line_text_field' });
+      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorData.colors), type: 'json' });
+      allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'color_name', value: colorData.dominantColor, type: 'single_line_text_field' });
+
+      progress.stats.colors++;
+      log(`  Phase 2 Color: ${colorData.dominantColor} (${colorData.dominantHex})`);
+    } catch (e) {
+      log(`  Phase 2 Color: ERROR — ${e.message?.slice(0, 80)}`);
+      progress.stats.errors++;
+    }
+    await sleep(1200); // Gemini rate limit
+  } else if (customs['color_details']) {
+    log(`  Phase 2 Color: already done`);
+    // Extract existing color name if available
+    try {
+      const existing = JSON.parse(customs['color_details']);
+      if (Array.isArray(existing) && existing[0]?.name) {
+        colorName = colorName || existing[0].name;
+      }
+    } catch {}
+  } else {
+    log(`  Phase 2 Color: no image — skipping`);
+  }
+
+  // ====== PHASE 3: Body rewrite (2 paragraphs, no specs, no hashtags) ======
+  const cleanTitle = extractPatternAndColor(p.title);
+  let aiDesc = '';
+
+  if (imgUrl) {
+    try {
+      const tmpImg2 = `/tmp/retro_img_${numericId}.jpg`;
+      if (!fs.existsSync(tmpImg2)) {
+        await downloadImage(imgUrl, tmpImg2);
+      }
+      const imgB64 = fs.readFileSync(tmpImg2).toString('base64');
+
+      const descResult = await geminiCall({
+        contents: [{ parts: [
+          { text: `Write exactly 2 sentences describing this wallcovering called "${cleanTitle}" by Retro Walls for a design-trade buyer. Sentence 1: What the design looks like (pattern, texture, colors). Sentence 2: Recommended setting/use (residential, hospitality, commercial). Do NOT use "wallpaper" — say "wallcovering". Do NOT use hashtags. Do NOT mention specs (width, material, fire rating). Keep it professional and concise.` },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+        ]}],
+        generationConfig: { temperature: 0.3, maxOutputTokens: 200 }
+      });
+
+      aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '')
+        .replace(/wallpaper/gi, 'wallcovering')
+        .replace(/#\w+/g, '')  // strip hashtags
+        .trim();
+
+      // Clean up temp file
+      try { fs.unlinkSync(tmpImg2); } catch {}
+    } catch (e) {
+      log(`  Phase 3 Body AI desc error: ${e.message?.slice(0, 50)}`);
+    }
+    await sleep(1200); // Gemini rate limit
+  }
+
+  const colorInfo = colorName ? ` in ${colorName}` : '';
+  const para1 = `${cleanTitle} is a vintage-inspired wallcovering${colorInfo} from Retro Walls, curated from an exclusive private collection of over 500 wallcovering books and rolls.`;
+  const para2 = aiDesc || `This distinctive design is perfect for feature walls in residential, hospitality, and commercial spaces, offering a timeless aesthetic that appeals to interior designers and restoration specialists alike.`;
+  const newBody = `<p>${para1}</p>\n<p>${para2}</p>`;
+
+  progress.stats.bodies++;
+  log(`  Phase 3 Body: 2 paragraphs written`);
+
+  // ====== PHASE 4: Title update — "Pattern - Color | Retro Walls" ======
+  let newTitle = p.title;
+  // Only update if not already in correct format
+  if (!p.title.includes('| Retro Walls')) {
+    const patternName = customs['pattern_name'] || cleanTitle;
+    // Use color from AI, existing metafield, or extract from title
+    const titleColor = colorName || '';
+    if (titleColor && !patternName.toLowerCase().includes(titleColor.toLowerCase())) {
+      newTitle = `${patternName} - ${titleColor} | Retro Walls`;
+    } else {
+      newTitle = `${patternName} | Retro Walls`;
+    }
+    // Clean up "Wallcovering" from pattern name in title since it's redundant
+    newTitle = newTitle.replace(/\s*Wallcovering\s*/g, ' ').replace(/\s+/g, ' ').trim();
+    progress.stats.titles++;
+    log(`  Phase 4 Title: ${newTitle}`);
+  } else {
+    log(`  Phase 4 Title: already formatted`);
+  }
+
+  // ====== PHASE 5: Set full_monty_date ======
+  allMfUpdates.push({ ownerId: pid, namespace: 'custom', key: 'full_monty_date', value: FULL_MONTY_DATE, type: 'single_line_text_field' });
+  progress.stats.fmDate++;
+
+  // ====== PUSH: Metafields (with retry on type errors) ======
+  if (allMfUpdates.length > 0) {
+    // Filter out known problematic fields (custom.color is product_reference, not text)
+    const safeMfUpdates = allMfUpdates.filter(m => !(m.namespace === 'custom' && m.key === 'color'));
+
+    try {
+      const r = await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }',
+        variables: { m: safeMfUpdates }
+      });
+      const errs = r?.data?.metafieldsSet?.userErrors || [];
+      if (errs.length > 0) {
+        // If type error, retry without the problematic fields
+        const typeErrors = errs.filter(e => e.message?.includes('type'));
+        if (typeErrors.length > 0 && safeMfUpdates.length > 1) {
+          log(`  Metafield type error — retrying critical fields only...`);
+          // Retry with just the essential fields (color data + full_monty_date)
+          const criticalKeys = new Set(['color_hex', 'background_color', 'color_details', 'color_name', 'full_monty_date']);
+          const criticalMf = safeMfUpdates.filter(m => criticalKeys.has(m.key));
+          if (criticalMf.length > 0) {
+            const r2 = await gql({
+              query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+              variables: { m: criticalMf }
+            });
+            const errs2 = r2?.data?.metafieldsSet?.userErrors || [];
+            if (errs2.length > 0) {
+              log(`  Critical metafield errors: ${errs2.map(e => e.message).join('; ')}`);
+              progress.stats.errors++;
+            } else {
+              log(`  Critical metafields saved OK (${criticalMf.length} fields)`);
+            }
+          }
+        } else {
+          log(`  Metafield errors: ${errs.map(e => e.message).join('; ')}`);
+          progress.stats.errors++;
+        }
+      }
+    } catch (e) {
+      log(`  Metafield push error: ${e.message?.slice(0, 60)}`);
+      progress.stats.errors++;
+    }
+    await sleep(500);
+  }
+
+  // ====== PUSH: Product update (title + body) ======
+  try {
+    const r = await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: pid, title: newTitle, descriptionHtml: newBody } }
+    });
+    const errs = r?.data?.productUpdate?.userErrors || [];
+    if (errs.length > 0) {
+      log(`  Product update errors: ${errs.map(e => e.message).join('; ')}`);
+      progress.stats.errors++;
+    }
+  } catch (e) {
+    log(`  Product update error: ${e.message?.slice(0, 60)}`);
+    progress.stats.errors++;
+  }
+  await sleep(500);
+
+  // ====== PHASE 6: Image alt tags (using media IDs) ======
+  const mediaItems = p.media?.edges || [];
+  if (mediaItems.length > 0) {
+    const patName = customs['pattern_name'] || cleanTitle;
+    const altColor = colorName || '';
+    const altBase = `${sku} ${patName} ${altColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
+
+    // Build media update list
+    const mediaUpdates = [];
+    let viewIdx = 0;
+    for (const mediaEdge of mediaItems) {
+      const media = mediaEdge.node;
+      if (media.mediaContentType !== 'IMAGE') continue;
+      // Skip spin GIFs (check URL from nested image)
+      const mediaUrl = media.image?.url || '';
+      if (mediaUrl.includes('spin.gif') || mediaUrl.includes('swatch-spin')) continue;
+
+      const suffix = viewIdx > 0 ? ` view-${viewIdx + 1}` : '';
+      const newAlt = `${altBase}${suffix}`;
+      viewIdx++;
+
+      // Skip if alt is already correct
+      if (media.alt === newAlt) continue;
+
+      mediaUpdates.push({ id: media.id, alt: newAlt });
+    }
+
+    if (mediaUpdates.length > 0) {
+      try {
+        // Batch all media alt updates in one call
+        const mediaPayload = mediaUpdates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/"/g, '\\"')}"}`).join(', ');
+        await gql({
+          query: `mutation { productUpdateMedia(productId: "${pid}", media: [${mediaPayload}]) { media { id } mediaUserErrors { message } } }`
+        });
+        progress.stats.alts++;
+        log(`  Phase 6 Alts: ${mediaUpdates.length} images updated`);
+      } catch (e) {
+        log(`  Phase 6 Alts: ERROR — ${e.message?.slice(0, 50)}`);
+      }
+      await sleep(500);
+    }
+  }
+
+  // Mark done
+  progress.done[numericId] = true;
+  saveProgress(progress);
+}
+
+// ---------- MAIN ----------
+async function main() {
+  log('=== RETRO WALLS FULL MONTY (NO ROOM SETTINGS) ===');
+  log(`Date: ${FULL_MONTY_DATE} | Target: ~1,052 products`);
+  log(`Pipeline: specs migrate → AI colors → body rewrite → title → full_monty_date → alt tags\n`);
+
+  const progress = loadProgress();
+  const alreadyDone = Object.keys(progress.done).length;
+  if (alreadyDone > 0) {
+    log(`RESUMING: ${alreadyDone} products already processed`);
+  }
+
+  // Fetch all products
+  log('Fetching all Retro Walls products...');
+  const products = await fetchAllProducts(null);
+  log(`Fetched ${products.length} total products\n`);
+
+  const total = products.length;
+  const startTime = Date.now();
+
+  for (let i = 0; i < products.length; i++) {
+    try {
+      await processProduct(products[i], progress, i + 1, total);
+    } catch (e) {
+      log(`  FATAL ERROR on product: ${e.message}`);
+      progress.stats.errors++;
+      saveProgress(progress);
+    }
+
+    // Progress report every 50 products
+    if ((i + 1) % 50 === 0) {
+      const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
+      const done = Object.keys(progress.done).length;
+      const rate = (done / (elapsed || 1)).toFixed(1);
+      log(`\n--- PROGRESS: ${done}/${total} done (${elapsed}min, ${rate}/min) ---`);
+      log(`    Specs: ${progress.stats.specs} | Colors: ${progress.stats.colors} | Bodies: ${progress.stats.bodies}`);
+      log(`    Titles: ${progress.stats.titles} | Alts: ${progress.stats.alts} | Errors: ${progress.stats.errors}\n`);
+    }
+  }
+
+  const elapsed = ((Date.now() - startTime) / 1000 / 60).toFixed(1);
+  log('\n=== RETRO WALLS FULL MONTY COMPLETE ===');
+  log(`Total products: ${total}`);
+  log(`Already done (skipped): ${progress.stats.skipped}`);
+  log(`Specs migrated: ${progress.stats.specs}`);
+  log(`Colors analyzed: ${progress.stats.colors}`);
+  log(`Bodies rewritten: ${progress.stats.bodies}`);
+  log(`Titles updated: ${progress.stats.titles}`);
+  log(`Alt tags set: ${progress.stats.alts}`);
+  log(`Full monty date set: ${progress.stats.fmDate}`);
+  log(`Errors: ${progress.stats.errors}`);
+  log(`Duration: ${elapsed} minutes`);
+
+  // Gemini cost estimate
+  const geminiCalls = progress.stats.colors + progress.stats.bodies;
+  const estCost = (geminiCalls * 0.0002).toFixed(2); // ~$0.0002 per flash call
+  log(`Gemini cost estimate: ~$${estCost} (${geminiCalls} calls @ ~$0.0002/call)`);
+}
+
+main().catch(e => {
+  log(`FATAL: ${e.message}\n${e.stack}`);
+  process.exit(1);
+});
diff --git a/scrape_dg_specs.js b/scrape_dg_specs.js
new file mode 100644
index 0000000..7fc6b7e
--- /dev/null
+++ b/scrape_dg_specs.js
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+/**
+ * Designers Guild Phase 2 Enrichment Scraper
+ * Scrapes individual product pages for full specs:
+ * fire_rating, repeat_v, repeat_h, match_type, roll length, weight, care, application, collection
+ * Saves to PostgreSQL designers_guild_catalog
+ */
+
+const https = require('https');
+const { Pool } = require('pg');
+
+const pool = new Pool({ connectionString: 'postgresql://dw_admin:DW2024SecurePass@127.0.0.1:5432/dw_unified' });
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function fetchPage(url) {
+  return new Promise((resolve, reject) => {
+    const parsed = new URL(url);
+    const req = https.get({
+      hostname: parsed.hostname,
+      path: parsed.pathname + parsed.search,
+      headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'text/html' }
+    }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        return fetchPage(res.headers.location).then(resolve).catch(reject);
+      }
+      let data = '';
+      res.on('data', c => data += c);
+      res.on('end', () => resolve({ status: res.statusCode, body: data }));
+    });
+    req.on('error', reject);
+    req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
+  });
+}
+
+function extractSpec(html, label) {
+  // Look for patterns like: <dt>Width</dt><dd>27.5 in</dd>
+  // or: "Width":"27.5 in"
+  // or: label followed by value in various HTML patterns
+
+  const patterns = [
+    new RegExp(`"${label}"\\s*:\\s*"([^"]*)"`, 'i'),
+    new RegExp(`>${label}<[^>]*>[^<]*<[^>]*>([^<]+)<`, 'i'),
+    new RegExp(`${label}[:\\s]*</[^>]+>\\s*<[^>]+>([^<]+)<`, 'i'),
+    new RegExp(`${label}[:\\s]+([^<\\n]+)`, 'i'),
+  ];
+
+  for (const pat of patterns) {
+    const m = html.match(pat);
+    if (m && m[1]?.trim()) return m[1].trim();
+  }
+  return null;
+}
+
+function extractAllSpecs(html) {
+  const specs = {};
+
+  // Width
+  specs.width = extractSpec(html, 'Width') || extractSpec(html, 'width');
+
+  // Repeat
+  specs.repeat_v = extractSpec(html, 'Vertical Pattern Repeat') || extractSpec(html, 'Vertical Repeat') || extractSpec(html, 'verticalRepeat');
+  specs.repeat_h = extractSpec(html, 'Horizontal Pattern Repeat') || extractSpec(html, 'Horizontal Repeat') || extractSpec(html, 'horizontalRepeat');
+
+  // Match type
+  specs.match_type = extractSpec(html, 'Pattern Match') || extractSpec(html, 'Match') || extractSpec(html, 'patternMatch');
+
+  // Fire rating
+  specs.fire_rating = extractSpec(html, 'Fire') || extractSpec(html, 'Flame') || extractSpec(html, 'fireRating');
+  // Also check for EN13501 pattern
+  const fireMatch = html.match(/EN13501[^<"]{0,50}Class\s+([A-E]-s\d-d\d)/i) || html.match(/(EN13501[^<"]{0,30})/i);
+  if (fireMatch && !specs.fire_rating) specs.fire_rating = fireMatch[1]?.trim();
+
+  // Collection
+  specs.collection = extractSpec(html, 'Collection') || extractSpec(html, 'collection');
+
+  // Roll length
+  specs.roll_length = extractSpec(html, 'Roll Length') || extractSpec(html, 'rollLength');
+
+  // Weight
+  specs.weight = extractSpec(html, 'Weight') || extractSpec(html, 'weight');
+
+  // Care/cleaning
+  specs.care = extractSpec(html, 'Care') || extractSpec(html, 'Cleaning') || extractSpec(html, 'careInstructions');
+
+  // Application/installation
+  specs.application = extractSpec(html, 'Installation') || extractSpec(html, 'Hanging') || extractSpec(html, 'Paste');
+
+  // Coverage
+  specs.coverage = extractSpec(html, 'Coverage') || extractSpec(html, 'coverage');
+
+  // Try JSON-LD structured data
+  const jsonLdMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g);
+  if (jsonLdMatch) {
+    for (const block of jsonLdMatch) {
+      try {
+        const json = JSON.parse(block.replace(/<script[^>]*>/, '').replace(/<\/script>/, ''));
+        if (json.additionalProperty) {
+          for (const prop of json.additionalProperty) {
+            const name = (prop.name || '').toLowerCase();
+            const val = prop.value;
+            if (name.includes('vertical') && name.includes('repeat') && !specs.repeat_v) specs.repeat_v = val;
+            if (name.includes('horizontal') && name.includes('repeat') && !specs.repeat_h) specs.repeat_h = val;
+            if (name.includes('match') && !specs.match_type) specs.match_type = val;
+            if (name.includes('fire') && !specs.fire_rating) specs.fire_rating = val;
+            if (name.includes('weight') && !specs.weight) specs.weight = val;
+            if (name.includes('collection') && !specs.collection) specs.collection = val;
+            if (name.includes('length') && !specs.roll_length) specs.roll_length = val;
+            if (name.includes('care') && !specs.care) specs.care = val;
+            if (name.includes('paste') || name.includes('install') && !specs.application) specs.application = val;
+          }
+        }
+      } catch {}
+    }
+  }
+
+  // Room setting images - look for lifestyle/room images
+  const roomImages = [];
+  const imgMatches = html.matchAll(/src=["']([^"']*(?:room|lifestyle|interior|setting|scene|styled)[^"']*\.(?:jpg|jpeg|png|webp))["']/gi);
+  for (const m of imgMatches) {
+    roomImages.push(m[1]);
+  }
+  // Also check for large images in the gallery
+  const galleryImgs = html.matchAll(/image-nonwebp\/\d+\/(\d+)/g);
+  const imageIds = new Set();
+  for (const m of galleryImgs) {
+    imageIds.add(m[1]);
+  }
+  specs.image_ids = [...imageIds];
+  specs.room_images = roomImages;
+
+  return specs;
+}
+
+function parseInches(val) {
+  if (!val) return null;
+  // "27.5 in" → 27.5
+  // "118 in" → 118
+  // "55 cm" → convert
+  const inMatch = val.match(/([\d.]+)\s*(?:in|inch)/i);
+  if (inMatch) return parseFloat(inMatch[1]);
+  const cmMatch = val.match(/([\d.]+)\s*cm/i);
+  if (cmMatch) return parseFloat(cmMatch[1]) / 2.54;
+  const numMatch = val.match(/([\d.]+)/);
+  if (numMatch) return parseFloat(numMatch[1]);
+  return null;
+}
+
+async function main() {
+  console.log('=== Designers Guild Phase 2 Enrichment Scraper ===\n');
+
+  // Ensure columns exist
+  const newCols = ['care_instructions', 'weight', 'roll_length', 'coverage', 'room_setting_urls'];
+  for (const col of newCols) {
+    await pool.query(`ALTER TABLE designers_guild_catalog ADD COLUMN IF NOT EXISTS ${col} TEXT`).catch(() => {});
+  }
+
+  // Get all products that need enrichment (no fire_rating = not yet scraped at product level)
+  const { rows } = await pool.query(`
+    SELECT id, mfr_sku, product_url
+    FROM designers_guild_catalog
+    WHERE product_url IS NOT NULL AND product_url != ''
+      AND (fire_rating IS NULL OR fire_rating = '')
+    ORDER BY id
+  `);
+
+  console.log(`Found ${rows.length} products to scrape\n`);
+
+  let success = 0, failed = 0, noData = 0;
+
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+
+    try {
+      const { status, body } = await fetchPage(row.product_url);
+
+      if (status !== 200) {
+        failed++;
+        if (i < 5) console.log(`  FAIL [${row.mfr_sku}]: HTTP ${status}`);
+        continue;
+      }
+
+      const specs = extractAllSpecs(body);
+
+      // Parse numeric values
+      const repeatV = parseInches(specs.repeat_v);
+      const repeatH = parseInches(specs.repeat_h);
+
+      // Update PostgreSQL
+      const updates = [];
+      const values = [];
+      let paramIdx = 1;
+
+      const addUpdate = (col, val) => {
+        if (val && val.toString().trim()) {
+          updates.push(`${col} = $${paramIdx}`);
+          values.push(val.toString().trim());
+          paramIdx++;
+        }
+      };
+
+      addUpdate('fire_rating', specs.fire_rating || 'N/A'); // Always set to mark as scraped
+      addUpdate('match_type', specs.match_type);
+      addUpdate('collection', specs.collection);
+      addUpdate('care_instructions', specs.care);
+      addUpdate('weight', specs.weight);
+      addUpdate('roll_length', specs.roll_length);
+      addUpdate('coverage', specs.coverage);
+      addUpdate('application', specs.application);
+      if (repeatV) { addUpdate('repeat_v', repeatV); }
+      if (repeatH) { addUpdate('repeat_h', repeatH); }
+      if (specs.room_images?.length > 0) {
+        addUpdate('room_setting_urls', JSON.stringify(specs.room_images));
+      }
+
+      if (updates.length > 0) {
+        values.push(row.id);
+        await pool.query(`UPDATE designers_guild_catalog SET ${updates.join(', ')} WHERE id = $${paramIdx}`, values);
+        success++;
+      } else {
+        noData++;
+      }
+
+    } catch (e) {
+      failed++;
+      if (i < 5) console.log(`  ERROR [${row.mfr_sku}]: ${e.message}`);
+    }
+
+    await sleep(1200); // Be nice to the server
+
+    if ((i + 1) % 50 === 0) {
+      console.log(`[${i + 1}/${rows.length}] success:${success} failed:${failed} noData:${noData}`);
+    }
+  }
+
+  console.log('\n=== COMPLETE ===');
+  console.log(`Total: ${rows.length} | Success: ${success} | Failed: ${failed} | No data: ${noData}`);
+
+  await pool.end();
+}
+
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/scripts/ralph-llm-runner.js b/scripts/ralph-llm-runner.js
new file mode 100755
index 0000000..c660401
--- /dev/null
+++ b/scripts/ralph-llm-runner.js
@@ -0,0 +1,286 @@
+#!/usr/bin/env node
+// Gemini-driven Ralph story runner.
+//
+// Replaces the `claude --dangerously-skip-permissions -p ...` invocation
+// in app/api/ralph/{run,continue}/route.ts with a Gemini 2.0 Flash call.
+//
+// Usage:
+//   node ralph-gemini-runner.js --prompt-file <story.txt> --project <dir>
+//
+// Behavior:
+//   1. Read story prompt
+//   2. Build context: project tree (top-level + src/app + recent edits) + tech stack hints
+//   3. Call Gemini with strict JSON-output schema:
+//      { "files": [{"path":"<rel>", "content":"<full file>"}], "shell": ["..."], "done": true/false, "error": "..." }
+//   4. Write each file, run optional shell cmds (read-only by default; --apply-shell to enable)
+//   5. Run typecheck (npm run build OR tsc --noEmit)
+//   6. Exit 0 on success, 1 on any failure
+//
+// No Claude API calls. No external Anthropic dependency.
+// Honest limits: Gemini is a stateless single-shot — much weaker than Claude
+// agentic loop. Stories that need iterative read→edit→test→edit will fail.
+// Better suited for "implement this single file" stories.
+
+const fs = require('fs');
+const path = require('path');
+const { execSync, spawnSync } = require('child_process');
+
+// ─── args ────────────────────────────────────────────────────────────────
+const args = process.argv.slice(2);
+const argMap = {};
+for (let i = 0; i < args.length; i += 2) argMap[args[i]] = args[i + 1];
+
+const PROMPT_FILE = argMap['--prompt-file'];
+const PROJECT = argMap['--project'];
+const APPLY_SHELL = args.includes('--apply-shell');
+const MAX_FILES = parseInt(argMap['--max-files'] || '10', 10);
+
+if (!PROMPT_FILE || !PROJECT) {
+  console.error('usage: ralph-gemini-runner.js --prompt-file <path> --project <path> [--apply-shell] [--max-files N]');
+  process.exit(2);
+}
+
+// Prefer a Ralph-dedicated key so this runner doesn't compete with Letsbegin's
+// other AI routes (/api/prd/generate, /api/url-info, etc.) for free-tier quota.
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY_RALPH
+  || process.env.GEMINI_API_KEY;
+const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent';
+
+// ─── context-building helpers ───────────────────────────────────────────
+function safeRead(p, max = 4000) {
+  try {
+    const s = fs.readFileSync(p, 'utf8');
+    return s.length > max ? s.slice(0, max) + '\n…[truncated]' : s;
+  } catch { return ''; }
+}
+
+function listTree(dir, depth = 0, max = 80) {
+  const out = [];
+  function walk(d, prefix) {
+    if (out.length >= max) return;
+    let entries = [];
+    try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
+    for (const e of entries) {
+      if (out.length >= max) return;
+      if (e.name.startsWith('.git') || e.name === 'node_modules' || e.name === '.next' || e.name === 'dist') continue;
+      const full = path.join(d, e.name);
+      if (e.isDirectory()) {
+        out.push(prefix + e.name + '/');
+        if (prefix.length < 12) walk(full, prefix + '  ');
+      } else {
+        out.push(prefix + e.name);
+      }
+    }
+  }
+  walk(dir, '');
+  return out.join('\n');
+}
+
+function buildContext(projectDir) {
+  const pkg = safeRead(path.join(projectDir, 'package.json'), 1500);
+  const tsconfig = safeRead(path.join(projectDir, 'tsconfig.json'), 800);
+  const tree = listTree(projectDir);
+  const claudeMd = safeRead(path.join(projectDir, 'CLAUDE.md'), 2000);
+  return [
+    '# Project tree',
+    '```',
+    tree,
+    '```',
+    '',
+    '# package.json',
+    '```json',
+    pkg,
+    '```',
+    '',
+    '# tsconfig.json',
+    '```json',
+    tsconfig,
+    '```',
+    claudeMd ? `# CLAUDE.md\n${claudeMd}` : '',
+  ].filter(Boolean).join('\n');
+}
+
+// ─── LLM backend ────────────────────────────────────────────────────────
+// Default: local Ollama (no API quota, free, private). Set RALPH_LLM=gemini
+// to use Google Gemini Flash instead.
+const LLM_BACKEND = (process.env.RALPH_LLM || 'ollama').toLowerCase();
+const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
+const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
+
+async function callGemini(prompt) {
+  if (!GEMINI_API_KEY) {
+    console.error('GEMINI_API_KEY_RALPH or GEMINI_API_KEY environment variable is required when RALPH_LLM=gemini');
+    process.exit(2);
+  }
+
+  const r = await fetch(`${GEMINI_ENDPOINT}?key=${GEMINI_API_KEY}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: {
+        temperature: 0.4,
+        maxOutputTokens: 8000,
+        responseMimeType: 'application/json',
+      },
+    }),
+  });
+  if (!r.ok) {
+    const err = await r.text();
+    throw new Error(`Gemini ${r.status}: ${err.slice(0, 200)}`);
+  }
+  const j = await r.json();
+  const text = j.candidates?.[0]?.content?.parts?.[0]?.text || '';
+  return text;
+}
+
+async function callOllama(prompt) {
+  // Note: don't use format:'json' — qwen3 is a reasoning model and the strict
+  // schema mode collides with its <think>...</think> phase, yielding {}.
+  // We strip the think block + extract JSON ourselves in the main flow.
+  const r = await fetch(`${OLLAMA_URL}/api/generate`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      model: OLLAMA_MODEL,
+      prompt,
+      stream: false,
+      options: {
+        temperature: 0.3,
+        num_predict: 16000,
+        num_ctx: 16384,
+      },
+    }),
+  });
+  if (!r.ok) {
+    const err = await r.text();
+    throw new Error(`Ollama ${r.status}: ${err.slice(0, 200)}`);
+  }
+  const j = await r.json();
+  return j.response || '';
+}
+
+async function callLLM(prompt) {
+  if (LLM_BACKEND === 'gemini') return callGemini(prompt);
+  return callOllama(prompt);
+}
+
+// ─── main ────────────────────────────────────────────────────────────────
+(async () => {
+  console.log(`[ralph-llm] backend: ${LLM_BACKEND}${LLM_BACKEND === 'ollama' ? ` (${OLLAMA_MODEL} @ ${OLLAMA_URL})` : ''}`);
+  console.log(`[ralph-llm] story: ${PROMPT_FILE}`);
+  console.log(`[ralph-llm] project: ${PROJECT}`);
+
+  const story = safeRead(PROMPT_FILE, 6000);
+  const ctx = buildContext(PROJECT);
+
+  const prompt = `${ctx}
+
+---
+
+# Your task (story to implement)
+
+${story}
+
+---
+
+# Output schema (return STRICT JSON only — no prose, no markdown fence)
+
+{
+  "files": [
+    { "path": "<relative path from project root>", "content": "<COMPLETE file contents — full replacement, not a diff>" }
+  ],
+  "shell": [ "<optional shell command to run, e.g. npm install foo>" ],
+  "done": true,
+  "summary": "<1-sentence what you did>"
+}
+
+Rules:
+- Return COMPLETE files, not diffs. If you touch a 200-line file, return all 200 lines.
+- Maximum ${MAX_FILES} files in this response.
+- Don't touch lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml).
+- Don't put binary content. Text/code only.
+- If the story needs information you don't have (e.g. a file you weren't shown), set done=false and put the reason in "summary".
+- Set done=true only when you believe the acceptance criteria are met.
+- shell[] is optional — only use for npm install / database migrations / similar. Tests/build are run automatically afterward.
+
+API ACCURACY (read carefully — most failures come from these):
+- Only import symbols that actually exist in the module. If unsure, prefer Node built-ins you're confident about over guessing.
+- node:test exports \`test\`, \`describe\`, \`it\`, \`before\`, \`after\`, \`beforeEach\`, \`afterEach\`, \`mock\`. It does NOT export \`expect\`. Use \`node:assert\` (or \`node:assert/strict\`) for assertions: \`import { strict as assert } from 'node:assert'; assert.equal(a, b);\`.
+- Do not invent matchers like \`.toBe()\`, \`.toEqual()\` unless the project actually uses jest/vitest/chai (check package.json devDependencies first).
+- Match the existing project's module system: if package.json has no \`"type": "module"\` and tsconfig targets CommonJS, use \`require()\` / CJS exports. Otherwise use ESM \`import\`/\`export\`.
+- For test files in TypeScript, the project must already have @types/node and a test runner configured. Don't add new devDependencies unless the story explicitly says so.
+
+Output the JSON now.`;
+
+  let result;
+  try {
+    const raw = await callLLM(prompt);
+    console.log(`[ralph-llm] returned ${raw.length} bytes`);
+    // qwen sometimes wraps JSON in <think>...</think> or ```json fences — strip those.
+    let cleaned = raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
+    const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/);
+    if (fence) cleaned = fence[1].trim();
+    const firstBrace = cleaned.indexOf('{');
+    if (firstBrace > 0) cleaned = cleaned.slice(firstBrace);
+    result = JSON.parse(cleaned);
+  } catch (e) {
+    console.error(`[ralph-llm] FAIL: ${e.message}`);
+    process.exit(1);
+  }
+
+  if (!result.done) {
+    console.error(`[ralph-llm] model set done=false: ${result.summary || 'no reason'}`);
+    process.exit(1);
+  }
+
+  // Apply file writes
+  const files = (result.files || []).slice(0, MAX_FILES);
+  for (const f of files) {
+    if (!f.path || typeof f.content !== 'string') continue;
+    if (path.isAbsolute(f.path) || f.path.includes('..')) {
+      console.error(`[ralph-llm] skip suspicious path: ${f.path}`);
+      continue;
+    }
+    if (/\b(package-lock\.json|yarn\.lock|pnpm-lock\.yaml)$/.test(f.path)) continue;
+    const full = path.join(PROJECT, f.path);
+    fs.mkdirSync(path.dirname(full), { recursive: true });
+    fs.writeFileSync(full, f.content);
+    console.log(`[ralph-llm] wrote ${f.path} (${f.content.length} bytes)`);
+  }
+
+  // Optional shell commands
+  if (APPLY_SHELL && Array.isArray(result.shell)) {
+    for (const cmd of result.shell.slice(0, 3)) {
+      console.log(`[ralph-llm] $ ${cmd}`);
+      const r = spawnSync(cmd, { cwd: PROJECT, shell: true, stdio: 'inherit', timeout: 120_000 });
+      if (r.status !== 0) {
+        console.error(`[ralph-llm] shell failed: ${cmd}`);
+        process.exit(1);
+      }
+    }
+  }
+
+  // Run typecheck. Use `set -o pipefail` so the head truncation doesn't mask
+  // tsc's non-zero exit. If tsc isn't on PATH (no local @typescript install),
+  // skip rather than fail — better than blocking on missing dev tooling.
+  console.log(`[ralph-llm] running typecheck…`);
+  const hasTsConfig = fs.existsSync(path.join(PROJECT, 'tsconfig.json'));
+  const hasLocalTsc = fs.existsSync(path.join(PROJECT, 'node_modules/.bin/tsc'));
+  let tcCmd;
+  if (!hasTsConfig) {
+    tcCmd = 'echo "no tsconfig — skipping typecheck"';
+  } else if (!hasLocalTsc) {
+    tcCmd = 'echo "no local tsc binary — skipping typecheck (run npm install to enable)"';
+  } else {
+    tcCmd = 'set -o pipefail; ./node_modules/.bin/tsc --noEmit 2>&1 | head -80';
+  }
+  const tc = spawnSync(tcCmd, { cwd: PROJECT, shell: true, stdio: 'inherit', timeout: 180_000 });
+  if (tc.status !== 0) {
+    console.error(`[ralph-llm] typecheck FAILED — story marked failed`);
+    process.exit(1);
+  }
+
+  console.log(`[ralph-llm] ✓ ${result.summary || 'story done'}`);
+  process.exit(0);
+})().catch(e => { console.error(`[ralph-llm] uncaught: ${e.message}`); process.exit(1); });
diff --git a/sequin_colors_titles.js b/sequin_colors_titles.js
new file mode 100644
index 0000000..25c6611
--- /dev/null
+++ b/sequin_colors_titles.js
@@ -0,0 +1,107 @@
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+function gql(body) {
+  return new Promise((res, rej) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
+    req.on('error', rej); req.setTimeout(30000, () => { req.destroy(); rej(new Error('timeout')); }); req.write(data); req.end();
+  });
+}
+
+function gemini(reqBody) {
+  return new Promise((res) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
+    req.on('error', ()=>res(null)); req.setTimeout(30000, ()=>{req.destroy();res(null)}); req.write(data); req.end();
+  });
+}
+
+function download(url, dest) {
+  return new Promise((res, rej) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, r => {
+      if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location) { file.close(); return download(r.headers.location, dest).then(res).catch(rej); }
+      r.pipe(file); file.on('finish', () => { file.close(); res(); });
+    }).on('error', e => { file.close(); rej(e); });
+  });
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function main() {
+  console.log('=== Sequin Color Analysis + Title Update ===\n');
+  const r = await gql({ query: '{ eel: products(first: 50, query: "eel skin sequin AND status:active") { edges { node { id title images(first:1) { edges { node { url } } } } } } sting: products(first: 50, query: "sting ray sequin AND status:active") { edges { node { id title images(first:1) { edges { node { url } } } } } } }' });
+
+  const products = [...r.data.eel.edges.map(e => e.node), ...r.data.sting.edges.map(e => e.node)];
+  // Deduplicate by ID
+  const seen = new Set();
+  const unique = products.filter(p => { if (seen.has(p.id)) return false; seen.add(p.id); return true; });
+  console.log(`${unique.length} unique products\n`);
+
+  let ok = 0, err = 0;
+  for (let i = 0; i < unique.length; i++) {
+    const p = unique[i];
+    const imgUrl = p.images?.edges?.[0]?.node?.url;
+    if (!imgUrl) { console.log(`[${i+1}] ${p.title} — no image, skip`); err++; continue; }
+
+    // Download image
+    const tmp = '/tmp/seq_color.jpg';
+    await download(imgUrl, tmp);
+    const b64 = fs.readFileSync(tmp).toString('base64');
+
+    // Gemini: get dominant color
+    const result = await gemini({
+      contents: [{ parts: [
+        { text: 'What is the dominant color of this sequin/glitter wallcovering? Reply with ONLY a color name like Gold, Silver, Red, Blue, Pink, Champagne, Bronze, Copper, etc. Just the color, nothing else.' },
+        { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+      ]}],
+      generationConfig: { temperature: 0.1, maxOutputTokens: 10 }
+    });
+
+    const color = (result?.candidates?.[0]?.content?.parts?.[0]?.text || '').trim();
+    if (!color || color.length > 30) { console.log(`[${i+1}] ${p.title} — bad color response`); err++; await sleep(1200); continue; }
+
+    // Build new title: "Pattern Name - Color | Glitter Walls"
+    const patternBase = p.title.split('|')[0].trim(); // "Eel Skin Sequin" or "Sting Ray Sequin"
+    const newTitle = `${patternBase} - ${color} | Glitter Walls`;
+
+    // Update title + set color metafield
+    await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: p.id, title: newTitle } }
+    });
+
+    await gql({
+      query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+      variables: { m: [
+        { ownerId: p.id, namespace: 'custom', key: 'color_name', value: color, type: 'single_line_text_field' },
+      ]}
+    });
+
+    ok++;
+    console.log(`[${i+1}/${unique.length}] ${newTitle}`);
+    await sleep(1500);
+  }
+
+  console.log(`\n=== DONE === Updated: ${ok} | Errors: ${err}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/sequin_full_monty.js b/sequin_full_monty.js
new file mode 100644
index 0000000..8dd7da5
--- /dev/null
+++ b/sequin_full_monty.js
@@ -0,0 +1,201 @@
+#!/usr/bin/env node
+/**
+ * Sequin Products Full Monty (63 Eel Skin + Sting Ray)
+ * 1. AI color analysis via Gemini → custom.color_hex, color_details, background_color, color
+ * 2. Body rewrite to 2 paragraphs (editorial + AI)
+ * 3. 2 residential room settings per product
+ */
+
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+function gql(body) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(body);
+    const req = https.request({
+      hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+    }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0,300) }); } }); });
+    req.on('error', reject);
+    req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+    req.write(data); req.end();
+  });
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+function downloadImage(url, dest) {
+  return new Promise((resolve, reject) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
+      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+        file.close(); return downloadImage(res.headers.location, dest).then(resolve).catch(reject);
+      }
+      res.pipe(file); file.on('finish', () => { file.close(); resolve(); });
+    }).on('error', e => { file.close(); reject(e); });
+  });
+}
+
+function geminiCall(reqBody, model) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${GEMINI_KEY}`);
+    const req = https.request({
+      hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, res => {
+      const chunks = [];
+      res.on('data', c => chunks.push(c));
+      res.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch { resolve(null); } });
+    });
+    req.on('error', () => resolve(null));
+    req.setTimeout(120000, () => { req.destroy(); resolve(null); });
+    req.write(data); req.end();
+  });
+}
+
+async function uploadImage(productId, imgBuffer, alt) {
+  const tmpFile = '/tmp/seq_room.png';
+  fs.writeFileSync(tmpFile, imgBuffer);
+  const fileSize = fs.statSync(tmpFile).size;
+
+  const stage = await gql({
+    query: `mutation { stagedUploadsCreate(input: [{resource: IMAGE, filename: "room.png", mimeType: "image/png", httpMethod: POST, fileSize: "${fileSize}"}]) { stagedTargets { url parameters { name value } resourceUrl } userErrors { message } } }`
+  });
+  const target = stage?.data?.stagedUploadsCreate?.stagedTargets?.[0];
+  if (!target) return null;
+
+  const curlArgs = ['-s', '-X', 'POST', target.url];
+  for (const p of target.parameters) curlArgs.push('-F', `${p.name}=${p.value}`);
+  curlArgs.push('-F', `file=@${tmpFile}`);
+  execFileSync('curl', curlArgs, { timeout: 30000 });
+
+  const pid = productId.includes('/') ? productId : `gid://shopify/Product/${productId}`;
+  const r = await gql({
+    query: `mutation { productCreateMedia(productId: "${pid}", media: [{originalSource: "${target.resourceUrl}", mediaContentType: IMAGE, alt: "${alt}"}]) { media { id } mediaUserErrors { message } } }`
+  });
+  return r?.data?.productCreateMedia?.media?.[0]?.id || null;
+}
+
+async function main() {
+  console.log('=== Sequin Full Monty (63 products) ===\n');
+
+  const r = await gql({ query: '{ eel: products(first: 50, query: "eel skin sequin AND status:active") { edges { node { id title bodyHtml images(first:1) { edges { node { url } } } metafields(first: 5, keys: ["custom.color_details","custom.color_hex"]) { edges { node { key value } } } } } } sting: products(first: 50, query: "sting ray sequin AND status:active") { edges { node { id title bodyHtml images(first:1) { edges { node { url } } } metafields(first: 5, keys: ["custom.color_details","custom.color_hex"]) { edges { node { key value } } } } } } }' });
+
+  const products = [...r.data.eel.edges.map(e => e.node), ...r.data.sting.edges.map(e => e.node)];
+  console.log(`${products.length} products\n`);
+
+  let colors = 0, bodies = 0, rooms = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const imgUrl = p.images?.edges?.[0]?.node?.url;
+    const hasColors = p.metafields?.edges?.some(m => m.node.key === 'color_details' && m.node.value);
+
+    console.log(`[${i+1}/${products.length}] ${p.title}`);
+
+    if (!imgUrl) { console.log('  No image — skip'); continue; }
+    const tmpImg = '/tmp/seq_img.jpg';
+    await downloadImage(imgUrl, tmpImg);
+
+    // 1. AI COLOR ANALYSIS
+    if (!hasColors) {
+      const imgB64 = fs.readFileSync(tmpImg).toString('base64');
+      const colorResult = await geminiCall({
+        contents: [{ parts: [
+          { text: 'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. 3-6 colors summing to 100%. Title Case.' },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64 } }
+        ]}],
+        generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+      }, 'gemini-2.0-flash');
+
+      try {
+        const text = colorResult?.candidates?.[0]?.content?.parts?.[0]?.text || '';
+        const clean = text.replace(/```json?\s*/g, '').replace(/```/g, '').trim();
+        const cd = JSON.parse(clean);
+
+        await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: [
+            { ownerId: p.id, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
+            { ownerId: p.id, namespace: 'custom', key: 'color_name', value: cd.dominantColor, type: 'single_line_text_field' },
+          ]}
+        });
+        colors++;
+        console.log(`  Color: ${cd.dominantColor} (${cd.dominantHex})`);
+      } catch { console.log('  Color: parse error'); errors++; }
+      await sleep(1200);
+    } else {
+      console.log('  Color: already done');
+      colors++;
+    }
+
+    // 2. BODY: 2 paragraphs with AI description
+    const imgB642 = fs.readFileSync(tmpImg).toString('base64');
+    const descResult = await geminiCall({
+      contents: [{ parts: [
+        { text: `Describe this sequin wallcovering called "${p.title}" by Glitter Walls in 2 sentences for a design-trade buyer. Mention the texture, shimmer, and recommended commercial/residential use. Do NOT use "wallpaper" — say "wallcovering".` },
+        { inlineData: { mimeType: 'image/jpeg', data: imgB642 } }
+      ]}],
+      generationConfig: { temperature: 0.3, maxOutputTokens: 200 }
+    }, 'gemini-2.0-flash');
+
+    const aiDesc = (descResult?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
+    const firstPara = `${p.title} is a luxurious sequin wallcovering by Glitter Walls. This eye-catching design adds dramatic shimmer and texture to any interior space.`;
+    const newBody = `<p>${firstPara}</p>\n<p>${aiDesc || 'A stunning decorative wallcovering perfect for feature walls in hospitality, retail, and residential settings.'}</p>`;
+
+    await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: p.id, bodyHtml: newBody } }
+    });
+    bodies++;
+    await sleep(1000);
+
+    // 3. ROOM SETTINGS: 2 residential
+    const imgB64Wall = fs.readFileSync(tmpImg).toString('base64');
+    const roomPrompts = [
+      { name: 'Bedroom', prompt: 'This image shows a sequin/glitter wallcovering texture. Use it EXACTLY as the background wall in a luxurious modern bedroom. Place a king bed with velvet bedding, mirrored nightstands with crystal lamps, soft carpet floor. Evening ambient lighting with warm glow. Photorealistic glamorous interior.' },
+      { name: 'Living Room', prompt: 'This image shows a sequin/glitter wallcovering texture. Use it EXACTLY as a feature wall behind a modern living room. Place a contemporary sofa, glass coffee table, statement floor lamp, and abstract art on adjacent wall. Soft evening lighting highlighting the shimmer. Photorealistic luxury interior.' },
+    ];
+
+    for (const room of roomPrompts) {
+      console.log(`  Room: ${room.name}...`);
+      const imgResult = await geminiCall({
+        contents: [{ parts: [
+          { text: room.prompt },
+          { inlineData: { mimeType: 'image/jpeg', data: imgB64Wall } }
+        ]}],
+        generationConfig: { responseModalities: ['TEXT', 'IMAGE'] }
+      }, 'gemini-2.5-flash-image');
+
+      for (const part of imgResult?.candidates?.[0]?.content?.parts || []) {
+        if (part.inlineData) {
+          const buf = Buffer.from(part.inlineData.data, 'base64');
+          const mid = await uploadImage(p.id, buf, `${p.title} - ${room.name}`);
+          if (mid) { rooms++; console.log(`  Room: ${room.name} uploaded (${(buf.length/1024).toFixed(0)}KB)`); }
+          break;
+        }
+      }
+      await sleep(2000);
+    }
+  }
+
+  console.log('\n=== COMPLETE ===');
+  console.log(`Colors: ${colors} | Bodies: ${bodies} | Rooms: ${rooms} | Errors: ${errors}`);
+}
+
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/sequin_monty_noimages.js b/sequin_monty_noimages.js
new file mode 100644
index 0000000..fc71c4f
--- /dev/null
+++ b/sequin_monty_noimages.js
@@ -0,0 +1,152 @@
+const https = require('https');
+const http = require('http');
+const fs = require('fs');
+
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+if (!GEMINI_KEY) {
+  throw new Error('GEMINI_API_KEY environment variable is required');
+}
+if (!TOKEN) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
+}
+
+function gql(body) {
+  return new Promise((res, rej) => {
+    const data = JSON.stringify(body);
+    const req = https.request({ hostname: STORE, path: '/admin/api/2024-10/graphql.json', method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>res(JSON.parse(c))); });
+    req.on('error', rej); req.setTimeout(30000, ()=>{req.destroy();rej(new Error('timeout'))}); req.write(data); req.end();
+  });
+}
+function gemini(reqBody) {
+  return new Promise((res) => {
+    const data = JSON.stringify(reqBody);
+    const url = new URL('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=' + GEMINI_KEY);
+    const req = https.request({ hostname: url.hostname, path: url.pathname + url.search, method: 'POST',
+      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
+    }, r => { let c=''; r.on('data',d=>c+=d); r.on('end',()=>{ try{res(JSON.parse(c))}catch{res(null)} }); });
+    req.on('error', ()=>res(null)); req.setTimeout(30000, ()=>{req.destroy();res(null)}); req.write(data); req.end();
+  });
+}
+function download(url, dest) {
+  return new Promise((res, rej) => {
+    const mod = url.startsWith('https') ? https : http;
+    const file = fs.createWriteStream(dest);
+    mod.get(url, {headers:{'User-Agent':'Mozilla/5.0'}}, r => {
+      if(r.statusCode>=300&&r.headers.location){file.close();return download(r.headers.location,dest).then(res).catch(rej)}
+      r.pipe(file); file.on('finish',()=>{file.close();res()});
+    }).on('error',e=>{file.close();rej(e)});
+  });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function main() {
+  console.log('=== Sequin Full Monty (NO room settings) ===\n');
+
+  const r = await gql({ query: '{ eel: products(first: 50, query: "eel skin sequin AND status:active") { edges { node { id title images(first:1) { edges { node { url } } } metafields(first: 3, keys:["custom.color_details","custom.width"]) { edges { node { key value } } } } } } sting: products(first: 50, query: "sting ray sequin AND status:active") { edges { node { id title images(first:1) { edges { node { url } } } metafields(first: 3, keys:["custom.color_details","custom.width"]) { edges { node { key value } } } } } } }' });
+
+  const all = [...r.data.eel.edges.map(e=>e.node), ...r.data.sting.edges.map(e=>e.node)];
+  const seen = new Set();
+  const products = all.filter(p => { if(seen.has(p.id)) return false; seen.add(p.id); return true; });
+  console.log(`${products.length} products\n`);
+
+  let colors = 0, specs = 0, bodies = 0, errors = 0;
+
+  for (let i = 0; i < products.length; i++) {
+    const p = products[i];
+    const imgUrl = p.images?.edges?.[0]?.node?.url;
+    const hasColors = p.metafields?.edges?.some(m => m.node.key === 'color_details' && m.node.value);
+    const hasWidth = p.metafields?.edges?.some(m => m.node.key === 'width' && m.node.value);
+
+    console.log(`[${i+1}/${products.length}] ${p.title.slice(0,40)}`);
+
+    // 1. AI COLOR ANALYSIS (if missing)
+    if (!hasColors && imgUrl) {
+      try {
+        const tmp = '/tmp/seq_mc.jpg';
+        await download(imgUrl, tmp);
+        const b64 = fs.readFileSync(tmp).toString('base64');
+
+        const cr = await gemini({
+          contents: [{ parts: [
+            { text: 'Analyze this wallcovering. Return ONLY valid JSON: {"dominantColor":"name","dominantHex":"#XXXXXX","backgroundColor":"name","backgroundHex":"#XXXXXX","colors":[{"name":"Color","hex":"#XXXXXX","percentage":40}]}. 3-6 colors summing to 100%. Title Case.' },
+            { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+          ]}],
+          generationConfig: { temperature: 0.2, maxOutputTokens: 500 }
+        });
+
+        const text = (cr?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/```json?\s*/g,'').replace(/```/g,'').trim();
+        const cd = JSON.parse(text);
+
+        await gql({
+          query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+          variables: { m: [
+            { ownerId: p.id, namespace: 'custom', key: 'color_hex', value: cd.dominantHex, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'background_color', value: cd.backgroundColor, type: 'single_line_text_field' },
+            { ownerId: p.id, namespace: 'custom', key: 'color_details', value: JSON.stringify(cd.colors), type: 'json' },
+          ]}
+        });
+        colors++;
+        console.log(`  Color: ${cd.dominantColor} (${cd.dominantHex})`);
+      } catch (e) { console.log(`  Color: error`); errors++; }
+      await sleep(1200);
+    } else { colors++; }
+
+    // 2. SPECS (if missing)
+    if (!hasWidth) {
+      await gql({
+        query: 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message } } }',
+        variables: { m: [
+          { ownerId: p.id, namespace: 'custom', key: 'width', value: '45 Inches', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'material', value: 'Sequin Wallcovering', type: 'multi_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'fire_rating', value: 'ASTM E84-98 Class A Rated', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'packaging', value: '11 Yard Bolts Only', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'unit_of_measure', value: 'Sold in 11 Yard Bolts Only', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'brand', value: 'Glitter Walls', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'match_type', value: 'Random Match', type: 'single_line_text_field' },
+          { ownerId: p.id, namespace: 'custom', key: 'pattern_repeat', value: 'N/A', type: 'single_line_text_field' },
+        ]}
+      });
+      specs++;
+    }
+
+    // 3. BODY (2 paragraphs)
+    const colorName = p.title.split(' - ')[1]?.split(' |')[0]?.trim() || 'Shimmer';
+    const patternBase = p.title.includes('Eel') ? 'Eel Skin Sequin' : 'Sting Ray Sequin';
+    const firstPara = `${patternBase} in ${colorName} is a luxurious sequin wallcovering by Glitter Walls. This eye-catching design delivers dramatic shimmer and light-catching texture to any interior.`;
+
+    let aiDesc = '';
+    if (imgUrl) {
+      try {
+        const b64 = fs.readFileSync('/tmp/seq_mc.jpg').toString('base64');
+        const dr = await gemini({
+          contents: [{ parts: [
+            { text: `Describe this ${colorName} sequin wallcovering in one sentence for a design-trade buyer. Focus on the texture, light play, and best commercial/residential uses. Do NOT say "wallpaper".` },
+            { inlineData: { mimeType: 'image/jpeg', data: b64 } }
+          ]}],
+          generationConfig: { temperature: 0.3, maxOutputTokens: 100 }
+        });
+        aiDesc = (dr?.candidates?.[0]?.content?.parts?.[0]?.text || '').replace(/wallpaper/gi, 'wallcovering').trim();
+      } catch {}
+      await sleep(1200);
+    }
+    if (!aiDesc) aiDesc = 'A stunning decorative wallcovering perfect for feature walls in hospitality, retail, and high-end residential settings.';
+
+    const newBody = `<p>${firstPara}</p>\n<p>${aiDesc}</p>`;
+    await gql({
+      query: 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message } } }',
+      variables: { i: { id: p.id, bodyHtml: newBody } }
+    });
+    bodies++;
+
+    await sleep(500);
+    if ((i+1) % 10 === 0) console.log(`  --- ${i+1}/${products.length} done ---`);
+  }
+
+  console.log(`\n=== COMPLETE ===`);
+  console.log(`Colors: ${colors} | Specs: ${specs} | Bodies: ${bodies} | Errors: ${errors}`);
+}
+main().catch(e => { console.error('Fatal:', e); process.exit(1); });
diff --git a/sync_brands_page.js b/sync_brands_page.js
new file mode 100644
index 0000000..216d917
--- /dev/null
+++ b/sync_brands_page.js
@@ -0,0 +1,458 @@
+#!/usr/bin/env node
+
+/**
+ * sync_brands_page.js
+ *
+ * Reads vendors from vendor_registry WHERE show_on_brands_page = true,
+ * fetches actual Shopify collection handles (via smart collection rules),
+ * generates alphabetized HTML with correct links,
+ * and pushes it to the Shopify "Designer Brands" page via REST API.
+ */
+
+const { Pool } = require('pg');
+const https = require('https');
+
+// --- Configuration ---
+const DB_URL = process.env.DATABASE_URL;
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const SHOPIFY_TOKEN = process.env.SHOPIFY_CONTENT_TOKEN;
+const SHOPIFY_PRODUCT_TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const BRANDS_PAGE_ID = 20309475440; // existing "Designer Brands" page
+const API_VERSION = '2024-10';
+
+if (!DB_URL || !SHOPIFY_TOKEN || !SHOPIFY_PRODUCT_TOKEN) {
+  throw new Error('DATABASE_URL, SHOPIFY_CONTENT_TOKEN, and SHOPIFY_PRODUCT_TOKEN environment variables are required');
+}
+
+// --- Helpers ---
+
+/**
+ * Convert a vendor name to a Shopify-compatible collection handle (fallback only).
+ * e.g. "Cole & Son" -> "cole-son", "Phillip Jeffries" -> "phillip-jeffries"
+ */
+function vendorNameToHandle(name) {
+  return name
+    .toLowerCase()
+    .normalize('NFD')
+    .replace(/[\u0300-\u036f]/g, '') // strip accents (e.g. Coordonné -> Coordonne)
+    .replace(/&/g, '')               // remove ampersands
+    .replace(/[^a-z0-9]+/g, '-')     // non-alphanumeric -> hyphens
+    .replace(/^-+|-+$/g, '')         // trim leading/trailing hyphens
+    .replace(/-{2,}/g, '-');         // collapse multiple hyphens
+}
+
+/**
+ * Fetch all smart collections with vendor rules from Shopify.
+ * Returns a map of normalized vendor name -> array of { handle, title }.
+ * Multiple collections can exist per vendor; we collect them all.
+ */
+async function fetchVendorCollectionMap() {
+  const vendorMap = {};
+  let url = `/admin/api/${API_VERSION}/smart_collections.json?limit=250`;
+
+  while (url) {
+    const { body, nextUrl } = await shopifyRequest('GET', url, null, true);
+    const collections = body.smart_collections || [];
+
+    for (const c of collections) {
+      const vendorRule = (c.rules || []).find(r => r.column === 'vendor');
+      if (vendorRule) {
+        const key = vendorRule.condition.toLowerCase().trim();
+        if (!vendorMap[key]) vendorMap[key] = [];
+        vendorMap[key].push({ handle: c.handle, title: c.title });
+      }
+    }
+
+    url = nextUrl;
+  }
+
+  return vendorMap;
+}
+
+/**
+ * From an array of collections for a vendor, pick the best handle.
+ * Prefers handles that contain the vendor name slug.
+ */
+function pickBestHandle(collections, vendorSlug) {
+  if (collections.length === 1) return collections[0].handle;
+
+  // 1. Exact slug match (e.g. "koroseal" matches handle "koroseal")
+  const exactMatch = collections.find(c => c.handle === vendorSlug);
+  if (exactMatch) return exactMatch.handle;
+
+  // 2. Handle starts with vendor slug (e.g. "koroseal-wallpaper-collection")
+  const startsMatch = collections.find(c => c.handle.startsWith(vendorSlug));
+  if (startsMatch) return startsMatch.handle;
+
+  // 3. Handle contains vendor slug
+  const containsMatch = collections.find(c => c.handle.includes(vendorSlug));
+  if (containsMatch) return containsMatch.handle;
+
+  // 4. Title contains vendor name (case-insensitive)
+  const titleMatch = collections.find(c =>
+    c.title.toLowerCase().includes(vendorSlug.replace(/-/g, ' '))
+  );
+  if (titleMatch) return titleMatch.handle;
+
+  // 5. Fallback to first
+  return collections[0].handle;
+}
+
+/**
+ * Look up the actual Shopify collection handle for a vendor name.
+ * Tries exact match, then stripped suffixes, then falls back to generated handle.
+ */
+function resolveCollectionHandle(displayName, vendorCollectionMap) {
+  const nameLC = displayName.toLowerCase().trim();
+  const vendorSlug = vendorNameToHandle(displayName);
+
+  // Exact match on vendor name
+  if (vendorCollectionMap[nameLC]) {
+    return pickBestHandle(vendorCollectionMap[nameLC], vendorSlug);
+  }
+
+  // Try without common suffixes (e.g. "Stout Textiles" -> "Stout")
+  const suffixes = [' wallcoverings', ' wallpaper', ' wallpapers', ' fabrics',
+    ' textiles', ' online', ' commercial wallcovering', ' commercial wallpaper',
+    ' wallpaper and fabrics', ' home wallpaper and fabrics', ' murals'];
+  for (const suffix of suffixes) {
+    if (nameLC.endsWith(suffix)) {
+      const stripped = nameLC.slice(0, -suffix.length);
+      if (vendorCollectionMap[stripped]) {
+        return pickBestHandle(vendorCollectionMap[stripped], vendorSlug);
+      }
+    }
+  }
+
+  // Try adding common suffixes to find vendor in map
+  for (const suffix of suffixes) {
+    if (vendorCollectionMap[nameLC + suffix]) {
+      return pickBestHandle(vendorCollectionMap[nameLC + suffix], vendorSlug);
+    }
+  }
+
+  // No collection found
+  return null;
+}
+
+/**
+ * Fetch all published Shopify pages (paginated).
+ * Returns a Set of page handles.
+ */
+async function fetchPageHandles() {
+  const handles = new Set();
+  let url = `/admin/api/${API_VERSION}/pages.json?limit=250&fields=handle,published_at`;
+
+  while (url) {
+    const { body, nextUrl } = await shopifyRequest('GET', url, null, true, SHOPIFY_TOKEN);
+    for (const p of (body.pages || [])) {
+      if (p.published_at) handles.add(p.handle);
+    }
+    url = nextUrl;
+  }
+
+  return handles;
+}
+
+/**
+ * Find the best matching vendor page handle from published Shopify pages.
+ * Tries several common patterns: vendor-{slug}, {slug}, {slug}-wallcoverings, etc.
+ */
+function resolvePageHandle(displayName, pageHandles) {
+  const slug = vendorNameToHandle(displayName);
+  const candidates = [
+    slug,
+    `vendor-${slug}`,
+    `${slug}-wallcoverings`,
+    `${slug}-wallpaper`,
+    `${slug}-fabrics`,
+  ];
+
+  for (const candidate of candidates) {
+    if (pageHandles.has(candidate)) return candidate;
+  }
+
+  // Partial match: find any page handle that starts with the slug
+  for (const handle of pageHandles) {
+    if (handle.startsWith(slug) || handle === `vendor-${slug}`) {
+      return handle;
+    }
+  }
+
+  return null;
+}
+
+/**
+ * Group vendors alphabetically by first letter.
+ */
+function groupByLetter(vendors) {
+  const groups = {};
+  for (const v of vendors) {
+    const displayName = v.display_name;
+    // First character, uppercase. Numbers go under '#'
+    let letter = displayName.charAt(0).toUpperCase();
+    if (/\d/.test(letter)) letter = '#';
+    if (!groups[letter]) groups[letter] = [];
+    groups[letter].push(v);
+  }
+  return groups;
+}
+
+/**
+ * Generate clean, responsive HTML for the brands page.
+ * collectionVendors link to /collections/{handle}
+ * pageVendors link to /pages/{handle} and appear at the bottom
+ */
+function generateHTML(collectionVendors, pageVendors) {
+  const allVendors = [...collectionVendors, ...pageVendors];
+  const groups = groupByLetter(collectionVendors);
+  // Sort letters: '#' first, then A-Z
+  const sortedLetters = Object.keys(groups).sort((a, b) => {
+    if (a === '#') return -1;
+    if (b === '#') return 1;
+    return a.localeCompare(b);
+  });
+
+  // Build the letter navigation bar (only for collection vendors)
+  const allLetters = ['#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')];
+  const navLinks = allLetters.map(letter => {
+    const exists = groups[letter];
+    if (exists) {
+      return `<a href="#brand-letter-${letter === '#' ? 'num' : letter}" style="display:inline-block;padding:4px 8px;margin:2px;color:#2c3e50;text-decoration:none;font-weight:600;border:1px solid #ddd;border-radius:4px;font-size:14px;transition:background 0.2s;">${letter}</a>`;
+    }
+    return `<span style="display:inline-block;padding:4px 8px;margin:2px;color:#ccc;font-size:14px;">${letter}</span>`;
+  }).join('');
+
+  // Build collection vendor sections by letter
+  const sections = sortedLetters.map(letter => {
+    const letterVendors = groups[letter];
+    const anchorId = letter === '#' ? 'num' : letter;
+
+    const vendorLinks = letterVendors.map(v => {
+      return `<li style="margin:6px 0;"><a href="/collections/${v.handle}" style="color:#2c3e50;text-decoration:none;font-size:16px;transition:color 0.2s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#2c3e50'">${v.display_name}</a></li>`;
+    }).join('\n          ');
+
+    return `
+    <div id="brand-letter-${anchorId}" style="margin-bottom:28px;">
+      <h2 style="font-size:24px;font-weight:700;color:#c0392b;border-bottom:2px solid #e74c3c;padding-bottom:6px;margin-bottom:12px;">${letter}</h2>
+      <ul style="list-style:none;padding:0;margin:0;columns:2;column-gap:40px;">
+          ${vendorLinks}
+      </ul>
+    </div>`;
+  }).join('\n');
+
+  // Build page vendors section at bottom
+  let pageSection = '';
+  if (pageVendors.length > 0) {
+    const pageLinks = pageVendors.map(v => {
+      return `<li style="margin:6px 0;"><a href="/pages/${v.handle}" style="color:#2c3e50;text-decoration:none;font-size:16px;transition:color 0.2s;" onmouseover="this.style.color='#c0392b'" onmouseout="this.style.color='#2c3e50'">${v.display_name}</a></li>`;
+    }).join('\n          ');
+
+    pageSection = `
+    <div id="brand-additional" style="margin-top:40px;padding-top:30px;border-top:2px solid #e74c3c;">
+      <h2 style="font-size:24px;font-weight:700;color:#c0392b;margin-bottom:16px;">Additional Brands</h2>
+      <p style="color:#7f8c8d;font-size:14px;margin-bottom:16px;">Contact us for pricing and availability on these exclusive brands.</p>
+      <ul style="list-style:none;padding:0;margin:0;columns:2;column-gap:40px;">
+          ${pageLinks}
+      </ul>
+    </div>`;
+  }
+
+  return `<div style="max-width:900px;margin:0 auto;padding:20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
+  <div style="text-align:center;margin-bottom:30px;">
+    <h1 style="font-size:32px;font-weight:700;color:#2c3e50;margin-bottom:8px;">Our Designer Brands</h1>
+    <p style="color:#7f8c8d;font-size:16px;margin-bottom:20px;">Explore our curated collection of ${allVendors.length} premier wallcovering brands.</p>
+    <div style="margin-bottom:20px;">
+      ${navLinks}
+    </div>
+  </div>
+  ${sections}
+  ${pageSection}
+  <div style="text-align:center;margin-top:40px;padding-top:20px;border-top:1px solid #eee;color:#95a5a6;font-size:13px;">
+    Last updated: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Los_Angeles' })}
+  </div>
+</div>`;
+}
+
+/**
+ * Make a Shopify REST API request.
+ * If rawPath is true, path is used as-is (for pagination URLs).
+ * Returns { body, nextUrl } when rawPath is true.
+ * tokenOverride lets callers specify which token to use.
+ */
+function shopifyRequest(method, path, body, rawPath = false, tokenOverride = null) {
+  return new Promise((resolve, reject) => {
+    const postData = body ? JSON.stringify(body) : null;
+    const fullPath = rawPath ? path : `/admin/api/${API_VERSION}${path}`;
+    const token = tokenOverride || (rawPath ? SHOPIFY_PRODUCT_TOKEN : SHOPIFY_TOKEN);
+    const options = {
+      hostname: SHOPIFY_STORE,
+      path: fullPath,
+      method,
+      headers: {
+        'X-Shopify-Access-Token': token,
+        'Content-Type': 'application/json',
+      },
+    };
+    if (postData) {
+      options.headers['Content-Length'] = Buffer.byteLength(postData);
+    }
+
+    const req = https.request(options, (res) => {
+      let data = '';
+      res.on('data', chunk => { data += chunk; });
+      res.on('end', () => {
+        if (res.statusCode >= 200 && res.statusCode < 300) {
+          let parsed;
+          try {
+            parsed = JSON.parse(data);
+          } catch {
+            parsed = data;
+          }
+
+          if (rawPath) {
+            // Extract pagination Link header
+            const linkHeader = res.headers['link'] || '';
+            const nextMatch = linkHeader.match(/<https:\/\/[^/]+([^>]+)>;\s*rel="next"/);
+            resolve({ body: parsed, nextUrl: nextMatch ? nextMatch[1] : null });
+          } else {
+            resolve(parsed);
+          }
+        } else {
+          reject(new Error(`Shopify API ${res.statusCode}: ${data}`));
+        }
+      });
+    });
+
+    req.on('error', reject);
+    if (postData) req.write(postData);
+    req.end();
+  });
+}
+
+// --- Main ---
+
+async function main() {
+  const pool = new Pool({ connectionString: DB_URL });
+
+  try {
+    // 1. Fetch actual Shopify collection handles + page handles
+    console.log('Fetching Shopify smart collections to build vendor→handle map...');
+    const vendorCollectionMap = await fetchVendorCollectionMap();
+    console.log(`  Found ${Object.keys(vendorCollectionMap).length} vendor-based smart collections`);
+
+    console.log('Fetching Shopify pages for fallback links...');
+    const pageHandles = await fetchPageHandles();
+    console.log(`  Found ${pageHandles.size} published pages`);
+
+    // 2. Read vendors from vendor_registry (including collection_handle override)
+    console.log('Connecting to PostgreSQL...');
+    const result = await pool.query(`
+      SELECT
+        vendor_name,
+        vendor_code,
+        is_private_label,
+        private_label_name,
+        collection_handle
+      FROM vendor_registry
+      WHERE show_on_brands_page = true
+      ORDER BY vendor_name ASC
+    `);
+
+    console.log(`Found ${result.rows.length} vendors with show_on_brands_page = true`);
+
+    if (result.rows.length === 0) {
+      console.error('No vendors found. Exiting.');
+      process.exit(1);
+    }
+
+    // 3. Split vendors into collection-linked vs page-linked
+    const collectionVendors = [];
+    const pageVendors = [];
+    const noLinkVendors = [];
+
+    for (const row of result.rows) {
+      const displayName = (row.is_private_label && row.private_label_name)
+        ? row.private_label_name
+        : row.vendor_name;
+
+      // Try collection handle: DB override > Shopify lookup
+      const collectionHandle = row.collection_handle
+        || resolveCollectionHandle(displayName, vendorCollectionMap);
+
+      if (collectionHandle) {
+        collectionVendors.push({
+          display_name: displayName,
+          handle: collectionHandle,
+          vendor_code: row.vendor_code,
+        });
+      } else {
+        // No collection — try to find a vendor page
+        const pageHandle = resolvePageHandle(displayName, pageHandles);
+        if (pageHandle) {
+          pageVendors.push({
+            display_name: displayName,
+            handle: pageHandle,
+            vendor_code: row.vendor_code,
+          });
+        } else {
+          noLinkVendors.push(displayName);
+        }
+      }
+    }
+
+    // Sort both lists
+    collectionVendors.sort((a, b) => a.display_name.localeCompare(b.display_name, 'en', { sensitivity: 'base' }));
+    pageVendors.sort((a, b) => a.display_name.localeCompare(b.display_name, 'en', { sensitivity: 'base' }));
+
+    console.log(`\n  Collection-linked: ${collectionVendors.length}`);
+    console.log(`  Page-linked (bottom): ${pageVendors.length}`);
+    if (noLinkVendors.length) {
+      console.log(`  No link found (skipped): ${noLinkVendors.length}`);
+      noLinkVendors.forEach(v => console.log(`    ⚠ ${v}`));
+    }
+
+    console.log('\nSample collection vendors:');
+    collectionVendors.slice(0, 5).forEach(v => {
+      console.log(`  ${v.display_name} -> /collections/${v.handle}`);
+    });
+    if (pageVendors.length) {
+      console.log('Sample page vendors:');
+      pageVendors.slice(0, 5).forEach(v => {
+        console.log(`  ${v.display_name} -> /pages/${v.handle}`);
+      });
+    }
+
+    // 4. Generate HTML
+    const html = generateHTML(collectionVendors, pageVendors);
+    console.log(`\nGenerated HTML: ${html.length} characters`);
+
+    // 5. Push to Shopify
+    const totalBrands = collectionVendors.length + pageVendors.length;
+    console.log(`\nPushing to Shopify page ID ${BRANDS_PAGE_ID}...`);
+
+    const response = await shopifyRequest('PUT', `/pages/${BRANDS_PAGE_ID}.json`, {
+      page: {
+        id: BRANDS_PAGE_ID,
+        body_html: html,
+        title: 'Designer Brands',
+      },
+    });
+
+    const page = response.page;
+    console.log(`\nSuccess! Page updated:`);
+    console.log(`  Title: ${page.title}`);
+    console.log(`  Handle: ${page.handle}`);
+    console.log(`  Updated at: ${page.updated_at}`);
+    console.log(`  URL: https://www.designerwallcoverings.com/pages/${page.handle}`);
+    console.log(`\n${totalBrands} brands published (${collectionVendors.length} collections + ${pageVendors.length} info pages).`);
+
+  } catch (err) {
+    console.error('Error:', err.message);
+    process.exit(1);
+  } finally {
+    await pool.end();
+  }
+}
+
+main();
diff --git a/test_brands.js b/test_brands.js
new file mode 100644
index 0000000..b1ba624
--- /dev/null
+++ b/test_brands.js
@@ -0,0 +1,450 @@
+#!/usr/bin/env node
+/**
+ * test_brands.js — Audit all 124 DW brands on Shopify
+ *
+ * 1. Pull vendors from vendor_registry WHERE show_on_brands_page = true
+ * 2. Query Shopify GraphQL productsCount for each vendor
+ * 3. For brands with 0 products, create a "Contact Us" page if one doesn't exist
+ * 4. Write report to /tmp/brands_audit.txt
+ */
+
+const https = require('https');
+const { Client } = require('pg');
+
+// Config
+const SHOPIFY_STORE = 'designer-laboratory-sandbox.myshopify.com';
+const PRODUCT_TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
+const CONTENT_TOKEN = process.env.SHOPIFY_CONTENT_TOKEN;
+const API_VERSION = '2024-10';
+const PG_URL = process.env.DATABASE_URL;
+
+if (!PRODUCT_TOKEN || !CONTENT_TOKEN || !PG_URL) {
+  throw new Error('SHOPIFY_PRODUCT_TOKEN, SHOPIFY_CONTENT_TOKEN, and DATABASE_URL environment variables are required');
+}
+
+// Rate limiting
+let shopifyBucket = 3900; // conservative start
+const BUCKET_MAX = 4000;
+const RESTORE_RATE = 200; // per second
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+/**
+ * Make a Shopify GraphQL request
+ */
+function shopifyGraphQL(query, token = PRODUCT_TOKEN) {
+  return new Promise((resolve, reject) => {
+    const postData = JSON.stringify({ query });
+    const options = {
+      hostname: SHOPIFY_STORE,
+      path: `/admin/api/${API_VERSION}/graphql.json`,
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Shopify-Access-Token': token,
+        'Content-Length': Buffer.byteLength(postData),
+      },
+    };
+
+    const req = https.request(options, (res) => {
+      let data = '';
+      res.on('data', (chunk) => (data += chunk));
+      res.on('end', () => {
+        try {
+          const json = JSON.parse(data);
+          if (json.extensions?.cost?.throttleStatus) {
+            shopifyBucket = json.extensions.cost.throttleStatus.currentlyAvailable;
+          }
+          resolve(json);
+        } catch (e) {
+          reject(new Error(`JSON parse error: ${data.substring(0, 200)}`));
+        }
+      });
+    });
+
+    req.on('error', reject);
+    req.write(postData);
+    req.end();
+  });
+}
+
+/**
+ * Make a Shopify REST API request
+ */
+function shopifyREST(method, path, body = null, token = CONTENT_TOKEN) {
+  return new Promise((resolve, reject) => {
+    const postData = body ? JSON.stringify(body) : null;
+    const options = {
+      hostname: SHOPIFY_STORE,
+      path: `/admin/api/${API_VERSION}${path}`,
+      method,
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Shopify-Access-Token': token,
+      },
+    };
+    if (postData) {
+      options.headers['Content-Length'] = Buffer.byteLength(postData);
+    }
+
+    const req = https.request(options, (res) => {
+      let data = '';
+      res.on('data', (chunk) => (data += chunk));
+      res.on('end', () => {
+        try {
+          const json = data ? JSON.parse(data) : {};
+          resolve({ status: res.statusCode, data: json, headers: res.headers });
+        } catch (e) {
+          reject(new Error(`REST parse error: ${data.substring(0, 200)}`));
+        }
+      });
+    });
+
+    req.on('error', reject);
+    if (postData) req.write(postData);
+    req.end();
+  });
+}
+
+/**
+ * Get product count for a vendor from Shopify
+ */
+async function getProductCount(vendorName) {
+  // Wait if bucket is low (each productsCount costs 10 points)
+  while (shopifyBucket < 100) {
+    console.log(`  [throttle] Bucket at ${shopifyBucket}, waiting 1s...`);
+    await sleep(1000);
+    shopifyBucket += RESTORE_RATE;
+    if (shopifyBucket > BUCKET_MAX) shopifyBucket = BUCKET_MAX;
+  }
+
+  // Escape the vendor name for GraphQL query string
+  const escaped = vendorName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+  const query = `{ productsCount(query: "vendor:\\"${escaped}\\"") { count } }`;
+
+  const result = await shopifyGraphQL(query);
+
+  if (result.errors) {
+    console.error(`  GraphQL error for "${vendorName}":`, result.errors[0]?.message);
+    return -1;
+  }
+
+  return result.data?.productsCount?.count ?? 0;
+}
+
+/**
+ * Get all existing pages from Shopify (paginated)
+ */
+async function getAllPages() {
+  const pages = [];
+  let sinceId = 0;
+
+  while (true) {
+    const path = `/pages.json?limit=250&fields=id,title,handle,template_suffix${sinceId ? `&since_id=${sinceId}` : ''}`;
+    const result = await shopifyREST('GET', path);
+
+    if (result.status !== 200) {
+      console.error(`Error fetching pages: ${result.status}`);
+      break;
+    }
+
+    const batch = result.data.pages || [];
+    pages.push(...batch);
+
+    if (batch.length < 250) break;
+    sinceId = batch[batch.length - 1].id;
+    await sleep(500); // REST rate limit
+  }
+
+  return pages;
+}
+
+/**
+ * Create a slug from a brand name
+ */
+function slugify(name) {
+  return name
+    .toLowerCase()
+    .replace(/[&]/g, 'and')
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '');
+}
+
+/**
+ * Build the contact page HTML for a brand
+ */
+function buildContactHTML(brandName) {
+  return `<div style="max-width:900px;margin:0 auto;padding:30px 20px;font-family:Georgia,serif;">
+<h2 style="text-align:center;color:#2c2420;font-size:28px;margin-bottom:8px;">${brandName}</h2>
+<p style="text-align:center;font-size:16px;line-height:1.7;color:#5a4a3a;margin-bottom:30px;">We carry ${brandName} products and can source any item from their current collections. Contact us for pricing, samples, and availability.</p>
+<div style="text-align:center;margin-top:20px;">
+<a href="/pages/contact-us" style="display:inline-block;padding:12px 32px;background:#2c2420;color:#fff;text-decoration:none;font-size:15px;letter-spacing:1px;border-radius:3px;">CONTACT OUR DESIGN TEAM</a>
+<p style="margin-top:15px;font-size:14px;color:#8a7a6a;">Or call us at <a href="tel:1-888-373-4564" style="color:#2c2420;text-decoration:underline;">1-888-373-4564</a></p>
+</div>
+</div>`;
+}
+
+/**
+ * Create a page on Shopify
+ */
+async function createPage(brandName, handle) {
+  const body = {
+    page: {
+      title: `${brandName} Wallcoverings`,
+      handle: handle,
+      body_html: buildContactHTML(brandName),
+      template_suffix: 'inquiry',
+      published: true,
+    },
+  };
+
+  const result = await shopifyREST('POST', '/pages.json', body);
+
+  if (result.status === 201 || result.status === 200) {
+    return { success: true, id: result.data.page?.id, handle: result.data.page?.handle };
+  } else {
+    return { success: false, error: JSON.stringify(result.data.errors || result.data).substring(0, 200), status: result.status };
+  }
+}
+
+/**
+ * Main execution
+ */
+async function main() {
+  const startTime = Date.now();
+  console.log('=== DW Brands Audit ===');
+  console.log(`Started: ${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })} PT\n`);
+
+  // 1. Connect to PostgreSQL and get all vendors
+  console.log('1. Fetching vendors from PostgreSQL...');
+  const pg = new Client({ connectionString: PG_URL });
+  await pg.connect();
+
+  const { rows: vendors } = await pg.query(`
+    SELECT vendor_code, vendor_name, is_private_label, private_label_name, shopify_count
+    FROM vendor_registry
+    WHERE show_on_brands_page = true
+    ORDER BY vendor_name
+  `);
+
+  console.log(`   Found ${vendors.length} vendors with show_on_brands_page = true\n`);
+
+  // 2. Get all existing pages from Shopify
+  console.log('2. Fetching existing Shopify pages...');
+  const existingPages = await getAllPages();
+  const pageHandles = new Set(existingPages.map(p => p.handle));
+  console.log(`   Found ${existingPages.length} existing pages\n`);
+
+  // 3. Query Shopify for product counts
+  console.log('3. Checking product counts on Shopify...\n');
+
+  const results = {
+    withProducts: [],
+    withoutProducts: [],
+    errors: [],
+  };
+
+  for (let i = 0; i < vendors.length; i++) {
+    const v = vendors[i];
+    const vendorName = v.vendor_name;
+
+    process.stdout.write(`   [${i + 1}/${vendors.length}] ${vendorName}... `);
+
+    const count = await getProductCount(vendorName);
+
+    if (count === -1) {
+      console.log('ERROR');
+      results.errors.push({ ...v, count: -1, error: 'GraphQL error' });
+    } else if (count > 0) {
+      console.log(`${count} products`);
+      results.withProducts.push({ ...v, shopify_live_count: count });
+    } else {
+      console.log('0 products');
+      results.withoutProducts.push({ ...v, shopify_live_count: 0 });
+    }
+
+    // Small delay between requests to stay within rate limits
+    await sleep(150);
+  }
+
+  console.log(`\n--- Summary ---`);
+  console.log(`With products: ${results.withProducts.length}`);
+  console.log(`Without products: ${results.withoutProducts.length}`);
+  console.log(`Errors: ${results.errors.length}\n`);
+
+  // 4. For brands with 0 products, create contact pages if they don't exist
+  console.log('4. Creating contact pages for brands with 0 products...\n');
+
+  const pagesCreated = [];
+  const pagesExisted = [];
+  const pageErrors = [];
+
+  for (const brand of results.withoutProducts) {
+    const slug = slugify(brand.vendor_name);
+    // Check multiple possible handle patterns
+    const possibleHandles = [
+      slug,
+      `${slug}-wallcoverings`,
+      `vendor-${slug}`,
+      brand.vendor_code,
+    ];
+
+    const existingPage = possibleHandles.find(h => pageHandles.has(h));
+
+    if (existingPage) {
+      console.log(`   [EXISTS] ${brand.vendor_name} → page handle: ${existingPage}`);
+      pagesExisted.push({ vendor: brand.vendor_name, handle: existingPage });
+      continue;
+    }
+
+    // Create the page
+    const handle = `${slug}-wallcoverings`;
+    process.stdout.write(`   [CREATE] ${brand.vendor_name} → ${handle}... `);
+
+    const result = await createPage(brand.vendor_name, handle);
+
+    if (result.success) {
+      console.log(`OK (ID: ${result.id})`);
+      pagesCreated.push({ vendor: brand.vendor_name, handle: result.handle, id: result.id });
+    } else {
+      console.log(`FAILED: ${result.error}`);
+      pageErrors.push({ vendor: brand.vendor_name, handle, error: result.error });
+    }
+
+    await sleep(500); // REST rate limit for writes
+  }
+
+  // 5. Write report
+  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
+  const timestamp = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
+
+  const report = [];
+  report.push('='.repeat(70));
+  report.push('DESIGNER WALLCOVERINGS — BRANDS AUDIT REPORT');
+  report.push(`Generated: ${timestamp} PT`);
+  report.push(`Duration: ${elapsed}s`);
+  report.push('='.repeat(70));
+  report.push('');
+  report.push(`Total brands tested: ${vendors.length}`);
+  report.push(`Brands WITH products: ${results.withProducts.length}`);
+  report.push(`Brands WITHOUT products: ${results.withoutProducts.length}`);
+  report.push(`Query errors: ${results.errors.length}`);
+  report.push('');
+
+  // Brands WITH products
+  report.push('-'.repeat(70));
+  report.push('BRANDS WITH PRODUCTS ON SHOPIFY');
+  report.push('-'.repeat(70));
+  report.push('');
+  report.push(padRight('Vendor Name', 45) + padRight('Count', 10) + 'DB Count');
+  report.push(padRight('-', 45, '-') + padRight('-', 10, '-') + '-------');
+
+  // Sort by count descending
+  results.withProducts.sort((a, b) => b.shopify_live_count - a.shopify_live_count);
+
+  let totalShopifyProducts = 0;
+  for (const brand of results.withProducts) {
+    totalShopifyProducts += brand.shopify_live_count;
+    const match = brand.shopify_live_count === brand.shopify_count ? '' : ' *MISMATCH*';
+    report.push(
+      padRight(brand.vendor_name, 45) +
+      padRight(String(brand.shopify_live_count), 10) +
+      `${brand.shopify_count}${match}`
+    );
+  }
+  report.push('');
+  report.push(`Total products across all brands: ${totalShopifyProducts}`);
+  report.push('');
+
+  // Brands WITHOUT products
+  report.push('-'.repeat(70));
+  report.push('BRANDS WITHOUT PRODUCTS ON SHOPIFY');
+  report.push('-'.repeat(70));
+  report.push('');
+  report.push(padRight('Vendor Name', 45) + padRight('DB Count', 10) + 'Page Status');
+  report.push(padRight('-', 45, '-') + padRight('-', 10, '-') + '-'.repeat(15));
+
+  for (const brand of results.withoutProducts) {
+    const created = pagesCreated.find(p => p.vendor === brand.vendor_name);
+    const existed = pagesExisted.find(p => p.vendor === brand.vendor_name);
+    const errored = pageErrors.find(p => p.vendor === brand.vendor_name);
+
+    let status = 'UNKNOWN';
+    if (created) status = `CREATED (${created.handle})`;
+    else if (existed) status = `EXISTS (${existed.handle})`;
+    else if (errored) status = `ERROR: ${errored.error}`;
+
+    report.push(
+      padRight(brand.vendor_name, 45) +
+      padRight(String(brand.shopify_count), 10) +
+      status
+    );
+  }
+
+  report.push('');
+
+  // Page creation summary
+  report.push('-'.repeat(70));
+  report.push('PAGE CREATION SUMMARY');
+  report.push('-'.repeat(70));
+  report.push(`Pages already existed: ${pagesExisted.length}`);
+  report.push(`Pages created: ${pagesCreated.length}`);
+  report.push(`Page creation errors: ${pageErrors.length}`);
+  report.push('');
+
+  if (pagesCreated.length > 0) {
+    report.push('Newly created pages:');
+    for (const p of pagesCreated) {
+      report.push(`  - ${p.vendor} → /pages/${p.handle} (ID: ${p.id})`);
+    }
+    report.push('');
+  }
+
+  if (pageErrors.length > 0) {
+    report.push('Failed page creations:');
+    for (const p of pageErrors) {
+      report.push(`  - ${p.vendor}: ${p.error}`);
+    }
+    report.push('');
+  }
+
+  // Errors
+  if (results.errors.length > 0) {
+    report.push('-'.repeat(70));
+    report.push('QUERY ERRORS');
+    report.push('-'.repeat(70));
+    for (const e of results.errors) {
+      report.push(`  - ${e.vendor_name}: ${e.error}`);
+    }
+    report.push('');
+  }
+
+  report.push('='.repeat(70));
+  report.push('END OF REPORT');
+  report.push('='.repeat(70));
+
+  const reportText = report.join('\n');
+
+  // Write to file
+  const fs = require('fs');
+  fs.writeFileSync('/tmp/brands_audit.txt', reportText);
+  console.log(`\n5. Report written to /tmp/brands_audit.txt`);
+  console.log(`   Duration: ${elapsed}s`);
+
+  // Print report to console too
+  console.log('\n' + reportText);
+
+  await pg.end();
+}
+
+function padRight(str, len, char = ' ') {
+  if (str.length >= len) return str.substring(0, len);
+  return str + char.repeat(len - str.length);
+}
+
+main().catch(err => {
+  console.error('Fatal error:', err);
+  process.exit(1);
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e7ff3a2
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,41 @@
+{
+  "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"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
+}

(oldest)  ·  back to Letsbegin  ·  url-info: hook Gemini call into cost-tracker (best-effort) 7a38d32 →