[object Object]

← back to Ventura Corridor

ventura-corridor: add /marketplace.html local listings board

e648a174a473d37df9f80e385220d24ab3fc9a07 · 2026-05-12 12:39:34 -0700 · SteveStudio2

Local-only marketplace for the Ventura Boulevard corridor (Sherman Oaks →
Woodland Hills). Mirrors the spirit of facebook.com/marketplace but is NOT
a mirror of FB data — Meta's TOS, BrandTotal/Voyager precedent, and image
copyright on user-uploaded photos rule out scraping. CHECK constraint
locks source_platform = 'local' so a future scraper can't silently bypass
the compliance gate.

What's in:
- db/migrations/023_marketplace.sql — marketplace_listings table + 16
  seed rows across 9 categories, source_platform locked to 'local'
- src/server/index.ts — GET /api/marketplace (filter/sort/search),
  GET /api/marketplace/categories (counts), POST /api/marketplace/post
  (neighbor submissions, validation only — no recaptcha yet)
- public/marketplace.html — dark noir/metal theme matching index.html,
  logo UL + hamburger UR, grid-search + sort + density slider per
  standing rule, category chip bar, detail modal, post-listing modal,
  FB-Marketplace deeplink footer (plain hyperlinks — allowed)
- public/index.html — added Marketplace link to the homepage nav stack

Public, no admin gate. Server stays bound to 127.0.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e648a174a473d37df9f80e385220d24ab3fc9a07
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 12:39:34 2026 -0700

    ventura-corridor: add /marketplace.html local listings board
    
    Local-only marketplace for the Ventura Boulevard corridor (Sherman Oaks →
    Woodland Hills). Mirrors the spirit of facebook.com/marketplace but is NOT
    a mirror of FB data — Meta's TOS, BrandTotal/Voyager precedent, and image
    copyright on user-uploaded photos rule out scraping. CHECK constraint
    locks source_platform = 'local' so a future scraper can't silently bypass
    the compliance gate.
    
    What's in:
    - db/migrations/023_marketplace.sql — marketplace_listings table + 16
      seed rows across 9 categories, source_platform locked to 'local'
    - src/server/index.ts — GET /api/marketplace (filter/sort/search),
      GET /api/marketplace/categories (counts), POST /api/marketplace/post
      (neighbor submissions, validation only — no recaptcha yet)
    - public/marketplace.html — dark noir/metal theme matching index.html,
      logo UL + hamburger UR, grid-search + sort + density slider per
      standing rule, category chip bar, detail modal, post-listing modal,
      FB-Marketplace deeplink footer (plain hyperlinks — allowed)
    - public/index.html — added Marketplace link to the homepage nav stack
    
    Public, no admin gate. Server stays bound to 127.0.0.1.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
  link                                              |   1 +
 ...a-corridor-auto-cover.plist.20260507-091547.bak |  32 +
 ...-corridor-auto-dedupe.plist.20260507-091547.bak |  32 +
 ...corridor-auto-publish.plist.20260507-091547.bak |  32 +
 ...rridor-corpus-summary.plist.20260507-091547.bak |  32 +
 ...dor-coverage-snapshot.plist.20260507-091547.bak |  32 +
 ...ntura-corridor-digest.plist.20260507-091547.bak |  34 +
 ...ra-corridor-ig-dryrun.plist.20260507-091547.bak |  32 +
 ...corridor-magazine-gen.plist.20260507-091547.bak |  32 +
 ...orridor-morning-email.plist.20260507-091547.bak |  32 +
 ...ura-corridor-snapshot.plist.20260507-091547.bak |  32 +
 ...tura-corridor-standup.plist.20260507-091547.bak |  32 +
 db/migrations/023_marketplace.sql                  |  65 ++
 public/hero.html                                   | 215 ++++++
 public/index.html                                  |   1 +
 public/marketplace.html                            | 717 +++++++++++++++++++++
 src/server/index.ts                                |  95 +++
 tmp/ig-drafts/2026-05-07.html                      |  28 +-
 tmp/ig-drafts/2026-05-07.json                      |  24 +-
 tmp/ig-drafts/index.html                           |   4 +-
 20 files changed, 1476 insertions(+), 28 deletions(-)

diff --git a/ link b/ link
new file mode 100644
index 0000000..b05ac06
--- /dev/null
+++ b/ link	
@@ -0,0 +1 @@
+=== map detail panel all --
diff --git a/.plist-backups/com.steve.ventura-corridor-auto-cover.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-auto-cover.plist.20260507-091547.bak
new file mode 100644
index 0000000..33175c9
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-auto-cover.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-auto-cover</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:auto-cover >> /Users/stevestudio2/Projects/ventura-corridor/logs/auto-cover.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>2</integer>
+    <key>Minute</key>
+    <integer>42</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-cover.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-cover.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-auto-dedupe.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-auto-dedupe.plist.20260507-091547.bak
new file mode 100644
index 0000000..4d0bce7
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-auto-dedupe.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-auto-dedupe</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:auto-dedupe >> /Users/stevestudio2/Projects/ventura-corridor/logs/auto-dedupe.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>3</integer>
+    <key>Minute</key>
+    <integer>33</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-dedupe.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-dedupe.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-auto-publish.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-auto-publish.plist.20260507-091547.bak
new file mode 100644
index 0000000..3b25390
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-auto-publish.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-auto-publish</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:auto-publish 10 >> /Users/stevestudio2/Projects/ventura-corridor/logs/auto-publish.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>2</integer>
+    <key>Minute</key>
+    <integer>22</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-publish.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/auto-publish.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-corpus-summary.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-corpus-summary.plist.20260507-091547.bak
new file mode 100644
index 0000000..6bb6423
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-corpus-summary.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-corpus-summary</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:corpus-summary >> /Users/stevestudio2/Projects/ventura-corridor/logs/corpus-summary.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>9</integer>
+    <key>Minute</key>
+    <integer>0</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/corpus-summary.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/corpus-summary.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-coverage-snapshot.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-coverage-snapshot.plist.20260507-091547.bak
new file mode 100644
index 0000000..db28ce0
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-coverage-snapshot.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-coverage-snapshot</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:coverage-snapshot >> /Users/stevestudio2/Projects/ventura-corridor/logs/coverage-snapshot.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>23</integer>
+    <key>Minute</key>
+    <integer>55</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/coverage-snapshot.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/coverage-snapshot.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-digest.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-digest.plist.20260507-091547.bak
new file mode 100644
index 0000000..5626cca
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-digest.plist.20260507-091547.bak
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-digest</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run digest >> /Users/stevestudio2/Projects/ventura-corridor/logs/digest.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Weekday</key>
+    <integer>1</integer>
+    <key>Hour</key>
+    <integer>9</integer>
+    <key>Minute</key>
+    <integer>3</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/digest.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/digest.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-ig-dryrun.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-ig-dryrun.plist.20260507-091547.bak
new file mode 100644
index 0000000..f2e674f
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-ig-dryrun.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-ig-dryrun</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:ig-dryrun >> /Users/stevestudio2/Projects/ventura-corridor/logs/ig-dryrun.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>8</integer>
+    <key>Minute</key>
+    <integer>2</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/ig-dryrun.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/ig-dryrun.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-magazine-gen.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-magazine-gen.plist.20260507-091547.bak
new file mode 100644
index 0000000..c875dcc
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-magazine-gen.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-magazine-gen</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:gen 20 >> /Users/stevestudio2/Projects/ventura-corridor/logs/magazine-gen.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>1</integer>
+    <key>Minute</key>
+    <integer>32</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/magazine-gen.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/magazine-gen.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-morning-email.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-morning-email.plist.20260507-091547.bak
new file mode 100644
index 0000000..30121a9
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-morning-email.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-morning-email</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:morning-email >> /Users/stevestudio2/Projects/ventura-corridor/logs/morning-email.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>8</integer>
+    <key>Minute</key>
+    <integer>7</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/morning-email.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/morning-email.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-snapshot.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-snapshot.plist.20260507-091547.bak
new file mode 100644
index 0000000..879565d
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-snapshot.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-snapshot</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run snapshot >> /Users/stevestudio2/Projects/ventura-corridor/logs/snapshot.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>23</integer>
+    <key>Minute</key>
+    <integer>57</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/snapshot.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/snapshot.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/.plist-backups/com.steve.ventura-corridor-standup.plist.20260507-091547.bak b/.plist-backups/com.steve.ventura-corridor-standup.plist.20260507-091547.bak
new file mode 100644
index 0000000..0f2515f
--- /dev/null
+++ b/.plist-backups/com.steve.ventura-corridor-standup.plist.20260507-091547.bak
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.steve.ventura-corridor-standup</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>cd /Users/stevestudio2/Projects/ventura-corridor && /usr/local/bin/npm run magazine:standup >> /Users/stevestudio2/Projects/ventura-corridor/logs/standup.log 2>&amp;1</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>7</integer>
+    <key>Minute</key>
+    <integer>50</integer>
+  </dict>
+  <key>StandardOutPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/standup.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/stevestudio2/Projects/ventura-corridor/logs/standup.err.log</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
+    <key>PGHOST</key>
+    <string>/tmp</string>
+  </dict>
+</dict>
+</plist>
diff --git a/db/migrations/023_marketplace.sql b/db/migrations/023_marketplace.sql
new file mode 100644
index 0000000..333f17b
--- /dev/null
+++ b/db/migrations/023_marketplace.sql
@@ -0,0 +1,65 @@
+-- 023_marketplace.sql
+-- LOCAL marketplace listings for the Ventura Boulevard corridor.
+-- LOCAL ONLY — no scraping of third-party platforms. Meta's TOS, BrandTotal/Voyager
+-- precedent, and ambiguous image rights on user-uploaded Marketplace photos
+-- mean we never mirror Facebook/Craigslist/OfferUp/Nextdoor data. The page
+-- deeplinks OUT to those platforms (plain hyperlinks are fine) but every row
+-- in this table is a neighbor posting on OUR site.
+--
+-- CHECK constraint locks source_platform = 'local' so a future scraper can't
+-- silently insert mirrored rows without first revising this migration and
+-- revisiting the compliance conversation that gated it.
+
+CREATE TABLE IF NOT EXISTS marketplace_listings (
+  id              BIGSERIAL PRIMARY KEY,
+  category        TEXT NOT NULL,                                -- Furniture, Electronics, Vehicles, Home Goods, Real Estate, Services, Jobs, Clothing, Free, Misc
+  title           TEXT NOT NULL,
+  description     TEXT,
+  price_cents     BIGINT,                                       -- NULL = free or "make offer"
+  condition       TEXT CHECK (condition IS NULL OR condition IN ('new','like-new','good','fair','parts')),
+  location_label  TEXT,                                         -- Sherman Oaks, Studio City, Tarzana, Encino, Woodland Hills
+  lat             DOUBLE PRECISION,
+  lng             DOUBLE PRECISION,
+  image_url       TEXT,                                         -- relative path inside /public, or first-party CDN; never a hot-link to FB/CL/OU
+  source_url      TEXT,                                         -- internal listing URL, or mailto:/tel: handoff to seller
+  source_platform TEXT NOT NULL DEFAULT 'local'
+                    CHECK (source_platform = 'local'),
+  seller_name     TEXT,
+  seller_contact  TEXT,                                         -- email or phone; NULL if listing relies on source_url
+  posted_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  fetched_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  expires_at      TIMESTAMPTZ,
+  status          TEXT NOT NULL DEFAULT 'active'
+                    CHECK (status IN ('active','sold','expired','removed'))
+);
+
+CREATE INDEX IF NOT EXISTS marketplace_category_idx     ON marketplace_listings (category);
+CREATE INDEX IF NOT EXISTS marketplace_status_idx       ON marketplace_listings (status);
+CREATE INDEX IF NOT EXISTS marketplace_posted_idx       ON marketplace_listings (posted_at DESC);
+CREATE INDEX IF NOT EXISTS marketplace_location_idx     ON marketplace_listings (location_label);
+CREATE INDEX IF NOT EXISTS marketplace_price_idx        ON marketplace_listings (price_cents);
+
+-- Seed: plausible LOCAL listings written for the corridor. Titles/descriptions
+-- composed for this seed — not scraped, not mirrored. source_url either points
+-- back to the corridor site, opens an email/phone to the (test) seller, or is
+-- NULL so the listing renders as info-only.
+INSERT INTO marketplace_listings
+  (category, title, description, price_cents, condition, location_label, lat, lng, source_url, source_platform, seller_name)
+VALUES
+  ('Furniture',   'Mid-century walnut credenza',             'Original 1960s Drexel Declaration credenza. Refinished top, original hardware. Pickup in Sherman Oaks. Photos on request.',                                                145000, 'good',     'Sherman Oaks',   34.1517, -118.4490, NULL, 'local', 'Sherman Oaks neighbor'),
+  ('Furniture',   'Restoration Hardware Cloud sofa, 9-ft',   'Pearl linen, three-piece. Bought 2023, lightly used, like-new. Pickup Studio City.',                                                                                       385000, 'like-new', 'Studio City',    34.1444, -118.3947, NULL, 'local', 'Studio City neighbor'),
+  ('Vehicles',    '2019 Tesla Model 3 Long Range',           'Single owner, 38k miles, white/black, FSD transferable. Full service history.',                                                                                          2895000, 'good',     'Encino',         34.1593, -118.5009, NULL, 'local', 'Encino neighbor'),
+  ('Electronics', 'Apple Studio Display 27" 5K',             'Nano-texture glass, tilt + height stand. Original box. Pickup in Tarzana.',                                                                                                135000, 'like-new', 'Tarzana',        34.1722, -118.5532, NULL, 'local', 'Tarzana neighbor'),
+  ('Electronics', 'Sonos Arc + Sub Gen 3 bundle',            'Both white. Pickup only, no shipping.',                                                                                                                                    125000, 'good',     'Sherman Oaks',   34.1502, -118.4561, NULL, 'local', 'Sherman Oaks neighbor'),
+  ('Home Goods',  'Vintage Persian Tabriz rug 9x12',         'Late-century, hand-knotted. Some wear at edges, no fading.',                                                                                                                78000, 'good',     'Encino',         34.1601, -118.5023, NULL, 'local', 'Encino neighbor'),
+  ('Home Goods',  'Sub-Zero 36" built-in fridge — for parts','Door seal failure, compressor still good. Free if you haul it from the curb.',                                                                                                  0, 'parts',    'Woodland Hills', 34.1684, -118.6059, NULL, 'local', 'Woodland Hills neighbor'),
+  ('Free',        'Free moving boxes — assorted sizes',      '40+ boxes plus packing paper. Curbside in Studio City. First come.',                                                                                                         NULL, 'good',     'Studio City',    34.1422, -118.3911, NULL, 'local', 'Studio City neighbor'),
+  ('Services',    'Wallpaper installation — corridor pro',   '20+ years installing for Sherman Oaks / Studio City interior designers. Insured.',                                                                                            NULL, NULL,       'Sherman Oaks',   34.1510, -118.4480, 'https://nationalpaperhangers.com', 'local', 'NPH installer'),
+  ('Services',    'Interior design consultation, 1 hr',      'On-site walkthrough plus mood board. Corridor-based studio.',                                                                                                                25000, NULL,       'Studio City',    34.1448, -118.3955, NULL, 'local', 'Corridor studio'),
+  ('Real Estate', '2BR / 2BA Sherman Oaks rental',           'Behind the boulevard. Hardwood, in-unit W/D, secure parking. 1-year lease.',                                                                                                385000, NULL,       'Sherman Oaks',   34.1496, -118.4504, NULL, 'local', 'Sherman Oaks neighbor'),
+  ('Real Estate', 'Studio City guest house — short term',    'Detached. Furnished. 30-day minimum. No smoking, no pets.',                                                                                                                 460000, NULL,       'Studio City',    34.1429, -118.3922, NULL, 'local', 'Studio City neighbor'),
+  ('Jobs',        'Front-of-house — neighborhood restaurant','Weekend dinner shifts. Tarzana. Hospitality experience preferred, will train.',                                                                                                NULL, NULL,       'Tarzana',        34.1715, -118.5524, NULL, 'local', 'Local employer'),
+  ('Jobs',        'Showroom assistant — Sherman Oaks studio','Part-time. Email-and-spreadsheet competent. Design-curious.',                                                                                                                 NULL, NULL,       'Sherman Oaks',   34.1505, -118.4485, 'mailto:steve@designerwallcoverings.com', 'local', 'Designer Wallcoverings'),
+  ('Clothing',    'Vintage Levi 501 lot — 5 pairs',          'All 32x32, big-E and red-tab. Sold as a set.',                                                                                                                                42000, 'good',     'Studio City',    34.1438, -118.3938, NULL, 'local', 'Studio City neighbor'),
+  ('Clothing',    'Pair of Hermes Oran sandals, size 38',    'Gold. Worn twice, original box. No trades.',                                                                                                                                  52000, 'like-new', 'Encino',         34.1597, -118.5015, NULL, 'local', 'Encino neighbor')
+ON CONFLICT DO NOTHING;
diff --git a/public/hero.html b/public/hero.html
new file mode 100644
index 0000000..88bc256
--- /dev/null
+++ b/public/hero.html
@@ -0,0 +1,215 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>The Corridor · spotlight</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@200;300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--accent:#6a3a1a;--rule:#2a2622}
+*,*::before,*::after{box-sizing:border-box}
+html,body{margin:0;height:100%;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300;overflow:hidden}
+
+.stage{position:fixed;inset:0;overflow:hidden}
+.slide{position:absolute;inset:0;opacity:0;transition:opacity 1500ms cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;justify-content:flex-end;padding:64px 72px 100px;pointer-events:none}
+.slide.on{opacity:1;pointer-events:auto}
+
+.slide .img{position:absolute;inset:0;background-size:cover;background-position:center;background-repeat:no-repeat;filter:saturate(0.85) contrast(1.05)}
+.slide .grad{position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,10,12,0.25) 0%,rgba(10,10,12,0.55) 45%,rgba(10,10,12,0.95) 100%)}
+
+.slide.no-img{background:radial-gradient(ellipse at top right,rgba(184,153,104,0.18) 0%,transparent 60%),radial-gradient(ellipse at bottom left,rgba(106,58,26,0.22) 0%,transparent 55%),var(--noir)}
+
+.slide .meta{position:relative;z-index:2;max-width:980px}
+.slide .kicker{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.5em;text-transform:uppercase;color:var(--metal-glow);margin-bottom:18px;opacity:.92}
+.slide h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:clamp(40px,7vw,108px);line-height:.98;margin:0 0 22px;color:#fff;letter-spacing:-0.01em;text-shadow:0 4px 30px rgba(0,0,0,0.5)}
+.slide h1 em{font-style:normal;color:var(--metal-glow)}
+.slide .sub{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:300;font-size:clamp(18px,1.7vw,28px);line-height:1.4;color:rgba(240,236,226,0.88);max-width:780px;margin-bottom:34px}
+.slide .pull{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:clamp(15px,1.4vw,22px);color:var(--metal-glow);border-left:2px solid var(--metal);padding-left:18px;margin:0 0 30px;max-width:680px;line-height:1.4}
+.slide .biz{font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:rgba(240,236,226,0.7)}
+.slide .biz a{color:var(--metal-glow);text-decoration:none;border-bottom:1px solid rgba(212,182,131,0.3);padding-bottom:1px}
+.slide .biz a:hover{border-color:var(--metal-glow)}
+
+/* Top frame — masthead + counter */
+.frame{position:fixed;top:0;left:0;right:0;z-index:100;display:flex;justify-content:space-between;align-items:baseline;padding:22px 32px;background:linear-gradient(180deg,rgba(10,10,12,0.85) 0%,rgba(10,10,12,0) 100%);pointer-events:none}
+.frame .brand{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:22px;color:rgba(240,236,226,0.92);letter-spacing:-0.01em;pointer-events:auto}
+.frame .brand em{color:var(--metal-glow);font-style:normal;font-weight:500}
+.frame .counter{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:rgba(240,236,226,0.55);pointer-events:auto}
+.frame .counter b{color:var(--metal-glow);font-weight:500}
+
+/* Bottom controls */
+.dock{position:fixed;bottom:32px;left:0;right:0;z-index:100;display:flex;gap:14px;justify-content:center;align-items:center}
+.dock button,.dock a{background:rgba(10,10,12,0.55);backdrop-filter:blur(10px);border:1px solid rgba(184,153,104,0.32);color:rgba(240,236,226,0.85);font-family:Inter,sans-serif;font-size:10px;letter-spacing:.32em;text-transform:uppercase;padding:10px 16px;cursor:pointer;text-decoration:none;transition:all 200ms}
+.dock button:hover,.dock a:hover{border-color:var(--metal-glow);color:var(--metal-glow)}
+.dock .play{min-width:46px;display:flex;align-items:center;justify-content:center;font-size:14px}
+.dock .dots{display:flex;gap:6px;align-items:center;padding:0 8px}
+.dock .dot{width:7px;height:7px;border-radius:50%;background:rgba(240,236,226,0.2);transition:all 200ms;cursor:pointer}
+.dock .dot.on{background:var(--metal-glow);box-shadow:0 0 12px rgba(212,182,131,0.6)}
+
+/* Progress bar — drains as the slide advances */
+.progress{position:fixed;top:0;left:0;right:0;height:2px;background:transparent;z-index:101}
+.progress .fill{height:100%;background:linear-gradient(90deg,var(--metal),var(--metal-glow));width:0;transition:width linear}
+
+@media (max-width:760px){
+  .slide{padding:120px 28px 110px}
+  .frame{padding:14px 18px}
+  .frame .brand{font-size:18px}
+  .dock{flex-wrap:wrap;padding:0 16px}
+}
+</style>
+</head>
+<body>
+<div class="frame">
+  <div class="brand">The <em>Corridor</em> · spotlight</div>
+  <div class="counter"><b id="curr">—</b> / <span id="total">—</span></div>
+</div>
+
+<div class="progress"><div class="fill" id="fill"></div></div>
+
+<div class="stage" id="stage"></div>
+
+<div class="dock">
+  <button id="prev" title="previous">←</button>
+  <button class="play" id="play" title="pause / play">⏸</button>
+  <button id="next" title="next">→</button>
+  <div class="dots" id="dots"></div>
+  <a href="/issue" title="read the full issue">★ issue</a>
+  <a href="/today.html" title="admin">⚙</a>
+</div>
+
+<script>
+const SLIDE_MS = 9000;
+const FETCH_LIMIT = 12;
+let slides = [];
+let idx = 0;
+let timer = null;
+let playing = true;
+let progressStart = 0;
+
+function escHtml(s){return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
+
+function renderSlide(f, hasImg) {
+  const headline = escHtml(f.headline || f.biz_name || '—');
+  const sub = escHtml(f.subhead || '');
+  const pull = f.pull_quote ? `<div class="pull">&ldquo;${escHtml(f.pull_quote)}&rdquo;</div>` : '';
+  const biz = escHtml(f.biz_name || '');
+  const city = escHtml(f.city || '');
+  const cat = escHtml(f.category_tag || '');
+  const imgUrl = f.cover_image_path ? `/covers/${escHtml(f.cover_image_path)}` : null;
+
+  const node = document.createElement('div');
+  node.className = 'slide' + (imgUrl ? '' : ' no-img');
+  node.innerHTML = `
+    ${imgUrl ? `<div class="img" style="background-image:url('${imgUrl}')"></div><div class="grad"></div>` : ''}
+    <div class="meta">
+      <div class="kicker">${cat || 'feature'} · the corridor</div>
+      <h1>${headline}</h1>
+      ${sub ? `<div class="sub">${sub}</div>` : ''}
+      ${pull}
+      <div class="biz">${biz}${city ? ' · ' + city : ''} &nbsp;·&nbsp; <a href="/magazine/${f.id}">read the full feature →</a></div>
+    </div>
+  `;
+  return node;
+}
+
+function show(n) {
+  if (!slides.length) return;
+  idx = ((n % slides.length) + slides.length) % slides.length;
+  document.querySelectorAll('.slide').forEach((el, i) => el.classList.toggle('on', i === idx));
+  document.querySelectorAll('.dot').forEach((el, i) => el.classList.toggle('on', i === idx));
+  document.getElementById('curr').textContent = String(idx + 1).padStart(2, '0');
+  // Reset progress
+  const fill = document.getElementById('fill');
+  fill.style.transition = 'none';
+  fill.style.width = '0%';
+  if (playing) {
+    requestAnimationFrame(() => {
+      fill.style.transition = `width ${SLIDE_MS}ms linear`;
+      fill.style.width = '100%';
+    });
+  }
+}
+
+function next() { show(idx + 1); }
+function prev() { show(idx - 1); }
+
+function startTimer() { if (timer) clearInterval(timer); timer = setInterval(next, SLIDE_MS); }
+function stopTimer() { if (timer) clearInterval(timer); timer = null; }
+
+document.getElementById('next').addEventListener('click', () => { next(); if (playing) startTimer(); });
+document.getElementById('prev').addEventListener('click', () => { prev(); if (playing) startTimer(); });
+document.getElementById('play').addEventListener('click', () => {
+  playing = !playing;
+  document.getElementById('play').textContent = playing ? '⏸' : '▶';
+  if (playing) { startTimer(); show(idx); } else { stopTimer(); document.getElementById('fill').style.transition = 'none'; document.getElementById('fill').style.width = '0%'; }
+});
+window.addEventListener('keydown', e => {
+  if (e.key === 'ArrowRight') { next(); if (playing) startTimer(); }
+  else if (e.key === 'ArrowLeft') { prev(); if (playing) startTimer(); }
+  else if (e.key === ' ') { e.preventDefault(); document.getElementById('play').click(); }
+});
+
+async function load() {
+  // Pull a random sample of published features. Fall back to lottery if /today not available.
+  let feats = [];
+  try {
+    const r = await fetch(`/api/magazine/lottery?n=${FETCH_LIMIT}`).then(r => r.json());
+    feats = r.features || [];
+  } catch {}
+  if (!feats.length) {
+    try {
+      const r = await fetch('/api/magazine?status=published&limit=20').then(r => r.json());
+      feats = (r.rows || []).filter(x => x.editorial && x.editorial.length > 100).slice(0, FETCH_LIMIT);
+      // Map shape to lottery's
+      feats = feats.map(x => ({
+        id: x.id, headline: x.headline, subhead: x.subhead, pull_quote: x.pull_quote,
+        category_tag: x.category_tag, biz_name: x.name, city: x.city
+      }));
+    } catch {}
+  }
+  // Pull current month's cover so it can lead off w/ image, if available
+  try {
+    const ci = await fetch('/api/issues/current').then(r => r.json());
+    const issue = ci?.issue;
+    if (issue?.cover_image_path && issue?.cover_feature_id) {
+      // Move cover to front, attach image_path
+      const idx = feats.findIndex(f => Number(f.id) === Number(issue.cover_feature_id));
+      const cover = idx >= 0 ? feats.splice(idx, 1)[0] : null;
+      if (cover) {
+        cover.cover_image_path = issue.cover_image_path;
+        feats.unshift(cover);
+      }
+    }
+  } catch {}
+
+  slides = feats;
+  if (!slides.length) {
+    document.getElementById('stage').innerHTML = `<div class="slide on no-img"><div class="meta"><div class="kicker">no published features yet</div><h1>The <em>Corridor</em></h1><div class="sub">Publish a feature on <a style="color:var(--metal-glow)" href="/magazine.html">/magazine.html</a> and it will appear here.</div></div></div>`;
+    document.getElementById('total').textContent = '0';
+    document.getElementById('curr').textContent = '00';
+    return;
+  }
+
+  const stage = document.getElementById('stage');
+  stage.innerHTML = '';
+  slides.forEach(f => stage.appendChild(renderSlide(f)));
+
+  const dots = document.getElementById('dots');
+  dots.innerHTML = '';
+  slides.forEach((_, i) => {
+    const d = document.createElement('div');
+    d.className = 'dot' + (i === 0 ? ' on' : '');
+    d.addEventListener('click', () => { show(i); if (playing) startTimer(); });
+    dots.appendChild(d);
+  });
+
+  document.getElementById('total').textContent = String(slides.length).padStart(2, '0');
+  show(0);
+  startTimer();
+}
+
+load();
+// Re-load every 5 min so newly published features rotate in
+setInterval(load, 5 * 60_000);
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index 3ac5578..55b1e58 100644
--- a/public/index.html
+++ b/public/index.html
@@ -148,6 +148,7 @@
   <a class="b-3d" href="/chains.html" style="margin-top:6px">Chains — the parking-economy story ↗</a>
   <a class="b-3d" href="/atlas.html" style="margin-top:6px">Atlas — corridor at a glance ↗</a>
   <a class="b-3d" href="/eulogy.html" style="margin-top:6px">Eulogy — websites that no longer answer ↗</a>
+  <a class="b-3d" href="/marketplace.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Marketplace</b> · local board ↗</a>
   <a class="b-3d" href="/today.html" style="margin-top:6px;border-color:var(--metal-glow);color:var(--metal-glow)"><b>Today</b> · daily action plan ↗</a>
   <a class="b-3d" href="/pitches.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">DW pitch pipeline (admin) ↗</a>
   <a class="b-3d" href="/linkedin.html" style="margin-top:6px;border-color:var(--metal);color:var(--metal)">LinkedIn agent (admin) ↗</a>
diff --git a/public/marketplace.html b/public/marketplace.html
new file mode 100644
index 0000000..74f6407
--- /dev/null
+++ b/public/marketplace.html
@@ -0,0 +1,717 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
+<link rel="stylesheet" href="/theme.css">
+<script src="/theme.js" defer></script>
+<title>Marketplace · Ventura Boulevard</title>
+<style>
+  :root {
+    --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724;
+    --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a;
+    --metal: #b89968; --metal-glow: #d4b683; --metal-deep: #8a7044;
+    --serif: "Cormorant Garamond", Georgia, serif;
+    --sans: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
+    --mono: 'JetBrains Mono', 'SFMono-Regular', Menlo, Consolas, monospace;
+    --cols: 4;
+  }
+  *, *::before, *::after { box-sizing: border-box; }
+  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
+
+  header {
+    position: sticky; top: 0; z-index: 1000;
+    padding: 18px 24px;
+    background: linear-gradient(180deg, rgba(10,10,12,.96), rgba(10,10,12,.86));
+    backdrop-filter: blur(10px);
+    border-bottom: 1px solid var(--rule);
+    display: flex; align-items: center; justify-content: space-between; gap: 24px;
+  }
+  .logo {
+    font-family: var(--serif); font-weight: 300; font-size: 22px; letter-spacing: -.005em;
+    color: var(--ink); text-decoration: none; line-height: 1;
+  }
+  .logo em { font-style: italic; color: var(--metal); }
+  .logo .pill {
+    display: inline-block; margin-left: 10px; padding: 3px 10px;
+    font-family: var(--sans); font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
+    color: var(--metal); border: 1px solid var(--metal); vertical-align: middle;
+  }
+  .hamburger {
+    display: inline-flex; align-items: center; gap: 8px;
+    background: transparent; border: 1px solid var(--rule);
+    padding: 8px 14px; color: var(--ink-mute); cursor: pointer;
+    font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
+    transition: color 160ms ease, border-color 160ms ease;
+  }
+  .hamburger:hover { color: var(--metal); border-color: var(--metal); }
+  .hamburger .bars { display: inline-flex; flex-direction: column; gap: 3px; }
+  .hamburger .bars i { display: block; width: 14px; height: 1px; background: currentColor; }
+
+  /* nav drawer (right-side) */
+  #drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 320px; max-width: 88vw;
+    background: var(--noir-rise); border-left: 1px solid var(--rule); z-index: 1500;
+    transform: translateX(100%); transition: transform 320ms cubic-bezier(.16,.84,.3,1);
+    overflow-y: auto; padding: 22px 22px 30px;
+  }
+  #drawer.show { transform: none; }
+  #drawer .dx { background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+    padding: 6px 10px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
+    margin-bottom: 18px;
+  }
+  #drawer a { display: block; padding: 10px 0; border-bottom: 1px solid var(--rule);
+    color: var(--ink-soft); font-size: 12px; letter-spacing: .04em; text-decoration: none;
+    font-family: var(--serif); font-style: italic;
+  }
+  #drawer a:hover { color: var(--metal); }
+  #drawer-mask { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 1400;
+    opacity: 0; pointer-events: none; transition: opacity 200ms ease;
+  }
+  #drawer-mask.show { opacity: 1; pointer-events: auto; }
+
+  .lede {
+    max-width: 1380px; margin: 36px auto 8px; padding: 0 24px;
+  }
+  .kicker {
+    font-size: 10px; letter-spacing: .3em; text-transform: uppercase;
+    color: var(--metal); font-weight: 500; margin-bottom: 12px;
+  }
+  h1 {
+    font-family: var(--serif); font-weight: 300; font-style: italic;
+    font-size: clamp(40px, 5.4vw, 72px); line-height: 1.04; margin: 0 0 12px;
+    letter-spacing: -.012em;
+  }
+  h1 em { font-style: normal; color: var(--metal); }
+  .deck {
+    font-family: var(--serif); font-style: italic; font-size: 18px; line-height: 1.5;
+    color: var(--ink-soft); max-width: 720px;
+  }
+  .deck a { color: var(--metal-glow); border-bottom: 1px dotted var(--metal-glow); text-decoration: none; }
+  .deck a:hover { color: var(--ink); border-bottom-color: var(--ink); }
+
+  /* Controls bar */
+  .controls {
+    max-width: 1380px; margin: 30px auto 0; padding: 0 24px;
+    display: grid; grid-template-columns: 1fr auto auto auto; gap: 12px; align-items: end;
+  }
+  @media (max-width: 880px) {
+    .controls { grid-template-columns: 1fr; }
+  }
+  .ctl-label {
+    display: block; font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
+    color: var(--ink-mute); margin: 0 0 6px;
+  }
+  .ctl-input, .ctl-select {
+    width: 100%; padding: 12px 16px; background: var(--noir-rise);
+    border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans);
+    font-size: 13px; font-weight: 300; outline: none; transition: border-color 200ms ease;
+  }
+  .ctl-input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
+  .ctl-input:focus, .ctl-select:focus { border-color: var(--metal); box-shadow: 0 0 0 3px rgba(184,153,104,0.12); }
+  .ctl-density { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: var(--noir-rise); border: 1px solid var(--rule); }
+  .ctl-density input[type=range] { width: 110px; accent-color: var(--metal); }
+  .ctl-density .v { font-family: var(--mono); font-size: 10px; color: var(--metal); letter-spacing: .12em; min-width: 26px; text-align: right; }
+
+  .cat-bar {
+    max-width: 1380px; margin: 18px auto 0; padding: 0 24px;
+    display: flex; gap: 8px; flex-wrap: wrap;
+  }
+  .cat-chip {
+    border: 1px solid var(--rule); background: transparent;
+    color: var(--ink-soft); padding: 8px 14px; font-size: 10px; letter-spacing: .2em;
+    text-transform: uppercase; cursor: pointer; font-family: var(--sans);
+    transition: color 160ms ease, border-color 160ms ease, background 160ms ease;
+  }
+  .cat-chip:hover { color: var(--metal); border-color: var(--metal); }
+  .cat-chip.active { color: var(--noir); background: var(--metal); border-color: var(--metal); }
+  .cat-chip .n { font-family: var(--serif); font-style: italic; opacity: .8; margin-left: 6px; font-size: 11px; }
+
+  /* Result count + post-listing CTA */
+  .meta-row {
+    max-width: 1380px; margin: 20px auto 0; padding: 0 24px;
+    display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;
+  }
+  .count { font-size: 10px; letter-spacing: .3em; text-transform: uppercase; color: var(--ink-mute); }
+  .count b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 15px; letter-spacing: 0; margin-right: 4px; font-variant-numeric: tabular-nums; }
+  .post-cta {
+    background: transparent; border: 1px solid var(--metal); color: var(--metal-glow);
+    padding: 10px 18px; font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
+    cursor: pointer; font-family: var(--sans); transition: all 200ms ease;
+  }
+  .post-cta:hover { background: var(--metal); color: var(--noir); }
+
+  /* Grid */
+  .grid {
+    max-width: 1380px; margin: 18px auto 60px; padding: 0 24px;
+    display: grid; gap: 18px;
+    grid-template-columns: repeat(var(--cols), 1fr);
+  }
+  @media (max-width: 1100px) { :root { --cols: 3; } }
+  @media (max-width: 760px)  { :root { --cols: 2; } }
+  @media (max-width: 460px)  { :root { --cols: 1; } }
+
+  .card {
+    background: var(--noir-rise); border: 1px solid var(--rule);
+    display: flex; flex-direction: column; cursor: pointer;
+    transition: border-color 200ms ease, transform 200ms ease;
+    text-decoration: none; color: inherit;
+  }
+  .card:hover { border-color: var(--metal); transform: translateY(-2px); }
+  .card .img {
+    aspect-ratio: 1 / 1; background: linear-gradient(135deg, #1a1815 0%, #2a2520 100%);
+    position: relative; overflow: hidden;
+  }
+  .card .img img { width: 100%; height: 100%; object-fit: cover; display: block; }
+  .card .img .placeholder {
+    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+    color: var(--metal-deep); font-family: var(--serif); font-style: italic; font-size: 15px;
+    padding: 18px; text-align: center; letter-spacing: .02em;
+  }
+  .card .img .cat-tag {
+    position: absolute; top: 10px; left: 10px;
+    font-size: 8px; letter-spacing: .26em; text-transform: uppercase;
+    color: var(--ink); background: rgba(10,10,12,.78); padding: 4px 8px;
+    border: 1px solid var(--metal); backdrop-filter: blur(4px);
+  }
+  .card .img .source {
+    position: absolute; top: 10px; right: 10px;
+    font-size: 8px; letter-spacing: .2em; text-transform: uppercase;
+    color: var(--metal-glow); background: rgba(10,10,12,.78); padding: 4px 8px;
+    border: 1px solid var(--rule); backdrop-filter: blur(4px);
+  }
+  .card .body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
+  .card .title {
+    font-family: var(--serif); font-weight: 400; font-size: 16px; line-height: 1.22;
+    color: var(--ink); letter-spacing: -.003em;
+    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+  }
+  .card .price {
+    font-family: var(--serif); font-style: italic; font-size: 18px;
+    color: var(--metal-glow); font-variant-numeric: tabular-nums; margin-top: 2px;
+  }
+  .card .price.free { color: var(--metal); }
+  .card .loc {
+    font-size: 10px; letter-spacing: .12em; color: var(--ink-mute);
+    text-transform: uppercase; margin-top: auto; padding-top: 6px;
+    display: flex; justify-content: space-between; align-items: baseline;
+  }
+  .card .loc .pin { color: var(--metal); font-family: var(--serif); font-style: italic; text-transform: none; font-size: 12px; letter-spacing: 0; }
+  .card .loc .date { font-family: var(--mono); font-size: 9px; }
+
+  .empty {
+    max-width: 1380px; margin: 80px auto; padding: 0 24px; text-align: center;
+    font-family: var(--serif); font-style: italic; font-size: 22px; color: var(--ink-mute);
+  }
+
+  /* Modal — detail panel */
+  #modal-mask {
+    position: fixed; inset: 0; background: rgba(0,0,0,.72); z-index: 2000;
+    display: none; align-items: center; justify-content: center; padding: 24px;
+    backdrop-filter: blur(6px);
+  }
+  #modal-mask.show { display: flex; }
+  #modal {
+    background: var(--noir-rise); border: 1px solid var(--rule);
+    max-width: 640px; width: 100%; max-height: 88vh; overflow-y: auto;
+    position: relative;
+  }
+  #modal .mx {
+    position: absolute; top: 14px; right: 14px;
+    background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+    padding: 6px 12px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
+  }
+  #modal .mx:hover { color: var(--metal); border-color: var(--metal); }
+  #modal .img { aspect-ratio: 16 / 10; background: #1a1815; border-bottom: 1px solid var(--rule); position: relative; overflow: hidden; }
+  #modal .img img { width: 100%; height: 100%; object-fit: cover; }
+  #modal .body { padding: 26px 28px 30px; }
+  #modal .mk { font-size: 9px; letter-spacing: .28em; text-transform: uppercase; color: var(--metal); margin: 0 0 10px; font-weight: 500; }
+  #modal h2 { font-family: var(--serif); font-weight: 400; font-size: 28px; line-height: 1.18; color: var(--ink); margin: 0 0 8px; letter-spacing: -.005em; }
+  #modal .mp { font-family: var(--serif); font-style: italic; font-size: 28px; color: var(--metal-glow); margin: 0 0 18px; }
+  #modal .md { font-size: 14px; line-height: 1.6; color: var(--ink-soft); margin: 0 0 18px; white-space: pre-wrap; }
+  #modal .mrow { display: flex; justify-content: space-between; padding: 10px 0; border-top: 1px solid var(--rule); font-size: 12px; }
+  #modal .mrow .k { color: var(--ink-mute); letter-spacing: .04em; }
+  #modal .mrow .v { color: var(--ink); font-family: var(--serif); font-style: italic; font-size: 13px; }
+  #modal .cta {
+    display: block; margin-top: 22px; padding: 14px 18px;
+    border: 1px solid var(--metal); color: var(--metal-glow);
+    font-size: 11px; letter-spacing: .22em; text-transform: uppercase; font-weight: 500;
+    text-align: center; text-decoration: none;
+    transition: background 200ms ease, color 200ms ease;
+  }
+  #modal .cta:hover { background: var(--metal); color: var(--noir); }
+
+  /* Post-listing form modal */
+  #post-mask {
+    position: fixed; inset: 0; background: rgba(0,0,0,.78); z-index: 2100;
+    display: none; align-items: center; justify-content: center; padding: 24px;
+    backdrop-filter: blur(6px);
+  }
+  #post-mask.show { display: flex; }
+  #post {
+    background: var(--noir-rise); border: 1px solid var(--rule);
+    max-width: 540px; width: 100%; max-height: 88vh; overflow-y: auto; padding: 28px;
+    position: relative;
+  }
+  #post h2 { font-family: var(--serif); font-style: italic; font-size: 28px; margin: 0 0 6px; color: var(--ink); font-weight: 400; }
+  #post .sub { font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; margin: 0 0 22px; }
+  #post .fld { margin-bottom: 14px; }
+  #post .fld label { display: block; font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-bottom: 6px; }
+  #post .fld input, #post .fld select, #post .fld textarea {
+    width: 100%; padding: 10px 14px; background: var(--noir); border: 1px solid var(--rule);
+    color: var(--ink); font-family: var(--sans); font-size: 13px; outline: none;
+  }
+  #post .fld input:focus, #post .fld select:focus, #post .fld textarea:focus { border-color: var(--metal); }
+  #post .fld textarea { resize: vertical; min-height: 90px; }
+  #post .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+  #post .submit {
+    width: 100%; padding: 14px; background: var(--metal); color: var(--noir);
+    border: none; font-size: 11px; letter-spacing: .26em; text-transform: uppercase;
+    font-weight: 500; cursor: pointer; margin-top: 10px;
+  }
+  #post .submit:hover { background: var(--metal-glow); }
+  #post .submit:disabled { opacity: .5; cursor: not-allowed; }
+  #post .pclose {
+    position: absolute; top: 14px; right: 14px;
+    background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+    padding: 6px 12px; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; cursor: pointer;
+  }
+  #post .msg { padding: 12px; font-size: 12px; margin-top: 12px; }
+  #post .msg.ok  { background: rgba(184,153,104,0.12); border: 1px solid var(--metal); color: var(--metal-glow); }
+  #post .msg.err { background: rgba(196,123,123,0.12); border: 1px solid #c47b7b; color: #c47b7b; }
+
+  /* FB Marketplace deeplink footer */
+  .fb-deeplink {
+    max-width: 1380px; margin: 40px auto; padding: 28px 24px;
+    border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule);
+    text-align: center;
+  }
+  .fb-deeplink .fk { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); margin-bottom: 14px; font-weight: 500; }
+  .fb-deeplink .fp { font-family: var(--serif); font-style: italic; font-size: 17px; color: var(--ink-soft); margin: 0 0 18px; line-height: 1.5; }
+  .fb-deeplink a {
+    display: inline-block; margin: 4px; padding: 10px 18px; border: 1px solid var(--rule);
+    color: var(--metal); font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
+    text-decoration: none; font-family: var(--sans); transition: all 200ms ease;
+  }
+  .fb-deeplink a:hover { background: var(--metal); color: var(--noir); border-color: var(--metal); }
+</style>
+</head>
+<body>
+
+<header>
+  <a class="logo" href="/">Ventura <em>Boulevard</em><span class="pill">Marketplace</span></a>
+  <button class="hamburger" aria-label="Menu" onclick="document.getElementById('drawer').classList.add('show');document.getElementById('drawer-mask').classList.add('show')">
+    <span class="bars"><i></i><i></i><i></i></span>
+    <span>Menu</span>
+  </button>
+</header>
+
+<div id="drawer-mask" onclick="document.getElementById('drawer').classList.remove('show');this.classList.remove('show')"></div>
+<aside id="drawer">
+  <button class="dx" onclick="document.getElementById('drawer').classList.remove('show');document.getElementById('drawer-mask').classList.remove('show')">close ×</button>
+  <a href="/">Corridor map</a>
+  <a href="/marketplace.html" style="color:var(--metal)">Marketplace</a>
+  <a href="/today.html">Today</a>
+  <a href="/issue">The issue</a>
+  <a href="/search.html">Search</a>
+  <a href="/news.html">News</a>
+  <a href="/atlas.html">Atlas</a>
+  <a href="/scoreboard.html">Scoreboard</a>
+  <a href="/3d.html">3D corridor</a>
+  <a href="/wall.html">The wall</a>
+  <a href="/timeline.html">Timeline</a>
+  <a href="/pantone.html">Pantone</a>
+  <a href="/feedback.html">Feedback</a>
+  <a href="/about.html">About</a>
+</aside>
+
+<section class="lede">
+  <p class="kicker">Local · neighborhood-to-neighborhood</p>
+  <h1>The corridor's <em>marketplace</em>.</h1>
+  <p class="deck">Furniture, electronics, vehicles, rentals, services, jobs — listed by neighbors along Ventura Boulevard, from Sherman Oaks to Woodland Hills. A local board for the corridor. Looking for a wider net? <a href="https://www.facebook.com/marketplace/?ref=app_tab" target="_blank" rel="noopener noreferrer">Facebook Marketplace ↗</a> covers all of LA.</p>
+</section>
+
+<div class="controls">
+  <div>
+    <label class="ctl-label" for="q">Search</label>
+    <input class="ctl-input" id="q" type="search" placeholder="Couch, Tesla, wallpaper installer…" autocomplete="off">
+  </div>
+  <div>
+    <label class="ctl-label" for="sort">Sort</label>
+    <select class="ctl-select" id="sort">
+      <option value="newest">Newest</option>
+      <option value="price_asc">Price ↑</option>
+      <option value="price_desc">Price ↓</option>
+      <option value="title">Title A→Z</option>
+    </select>
+  </div>
+  <div>
+    <label class="ctl-label" for="maxp">Max price</label>
+    <select class="ctl-select" id="maxp">
+      <option value="0">Any</option>
+      <option value="10000">Under $100</option>
+      <option value="50000">Under $500</option>
+      <option value="100000">Under $1,000</option>
+      <option value="500000">Under $5,000</option>
+      <option value="2500000">Under $25,000</option>
+    </select>
+  </div>
+  <div>
+    <label class="ctl-label" for="density">Density</label>
+    <div class="ctl-density">
+      <input id="density" type="range" min="2" max="6" step="1" value="4">
+      <span class="v" id="density-v">4</span>
+    </div>
+  </div>
+</div>
+
+<div class="cat-bar" id="cat-bar"></div>
+
+<div class="meta-row">
+  <div class="count"><b id="count">—</b> listings on the corridor</div>
+  <button class="post-cta" onclick="openPost()">+ Post a listing</button>
+</div>
+
+<div class="grid" id="grid"></div>
+<div class="empty" id="empty" style="display:none">Nothing matches that filter yet.</div>
+
+<section class="fb-deeplink">
+  <div class="fk">Wider net · external links</div>
+  <p class="fp">The corridor board above is local-only. For all of LA, Facebook Marketplace deeplinks below — opens on facebook.com.</p>
+  <a href="https://www.facebook.com/marketplace/?ref=app_tab" target="_blank" rel="noopener noreferrer">All categories</a>
+  <a href="https://www.facebook.com/marketplace/category/furniture" target="_blank" rel="noopener noreferrer">Furniture</a>
+  <a href="https://www.facebook.com/marketplace/category/electronics" target="_blank" rel="noopener noreferrer">Electronics</a>
+  <a href="https://www.facebook.com/marketplace/category/vehicles" target="_blank" rel="noopener noreferrer">Vehicles</a>
+  <a href="https://www.facebook.com/marketplace/category/propertyrentals" target="_blank" rel="noopener noreferrer">Rentals</a>
+  <a href="https://www.facebook.com/marketplace/category/apparel" target="_blank" rel="noopener noreferrer">Clothing</a>
+  <a href="https://www.facebook.com/marketplace/category/free" target="_blank" rel="noopener noreferrer">Free</a>
+  <a href="https://www.facebook.com/marketplace/category/jobs" target="_blank" rel="noopener noreferrer">Jobs</a>
+</section>
+
+<!-- Detail modal -->
+<div id="modal-mask" onclick="if(event.target===this)closeModal()">
+  <div id="modal">
+    <button class="mx" onclick="closeModal()">close ×</button>
+    <div id="modal-body"></div>
+  </div>
+</div>
+
+<!-- Post-listing modal -->
+<div id="post-mask" onclick="if(event.target===this)closePost()">
+  <div id="post">
+    <button class="pclose" onclick="closePost()">close ×</button>
+    <h2>Post a listing</h2>
+    <p class="sub">Visible to corridor neighbors. No fees, no algorithm.</p>
+    <form id="post-form" onsubmit="return submitPost(event)">
+      <div class="fld">
+        <label for="p-cat">Category *</label>
+        <select id="p-cat" required>
+          <option value="">— pick one —</option>
+          <option>Furniture</option>
+          <option>Electronics</option>
+          <option>Vehicles</option>
+          <option>Home Goods</option>
+          <option>Real Estate</option>
+          <option>Services</option>
+          <option>Jobs</option>
+          <option>Clothing</option>
+          <option>Free</option>
+          <option>Misc</option>
+        </select>
+      </div>
+      <div class="fld">
+        <label for="p-title">Title *</label>
+        <input id="p-title" type="text" maxlength="200" required placeholder="Mid-century walnut credenza">
+      </div>
+      <div class="row2">
+        <div class="fld">
+          <label for="p-price">Price (USD) — blank if free</label>
+          <input id="p-price" type="number" min="0" step="1" placeholder="450">
+        </div>
+        <div class="fld">
+          <label for="p-condition">Condition</label>
+          <select id="p-condition">
+            <option value="">—</option>
+            <option value="new">New</option>
+            <option value="like-new">Like new</option>
+            <option value="good">Good</option>
+            <option value="fair">Fair</option>
+            <option value="parts">For parts</option>
+          </select>
+        </div>
+      </div>
+      <div class="fld">
+        <label for="p-desc">Description</label>
+        <textarea id="p-desc" maxlength="4000" placeholder="Condition, age, dimensions, why you're selling…"></textarea>
+      </div>
+      <div class="row2">
+        <div class="fld">
+          <label for="p-loc">Neighborhood</label>
+          <select id="p-loc">
+            <option value="">—</option>
+            <option>Sherman Oaks</option>
+            <option>Studio City</option>
+            <option>Tarzana</option>
+            <option>Encino</option>
+            <option>Woodland Hills</option>
+          </select>
+        </div>
+        <div class="fld">
+          <label for="p-name">Your name</label>
+          <input id="p-name" type="text" maxlength="80" placeholder="Alex K.">
+        </div>
+      </div>
+      <div class="fld">
+        <label for="p-contact">Contact (email or phone)</label>
+        <input id="p-contact" type="text" maxlength="120" placeholder="alex@example.com">
+      </div>
+      <button class="submit" id="p-submit" type="submit">Post listing</button>
+      <div id="p-msg" style="display:none"></div>
+    </form>
+  </div>
+</div>
+
+<script>
+const $ = (id) => document.getElementById(id);
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+
+// ─── State ─────────────────────────────────────────────────────────────
+let CURRENT_CAT = 'all';
+let LISTINGS = [];
+let CATEGORIES = [];
+
+// localStorage-persisted UI prefs
+const PREF_KEY = 'vc-mp-prefs';
+function loadPrefs() {
+  try { return JSON.parse(localStorage.getItem(PREF_KEY) || '{}'); } catch { return {}; }
+}
+function savePrefs(p) {
+  try { localStorage.setItem(PREF_KEY, JSON.stringify(p)); } catch {}
+}
+
+const prefs = loadPrefs();
+if (prefs.sort)    $('sort').value    = prefs.sort;
+if (prefs.maxp)    $('maxp').value    = prefs.maxp;
+if (prefs.density) { $('density').value = prefs.density; document.documentElement.style.setProperty('--cols', prefs.density); $('density-v').textContent = prefs.density; }
+if (prefs.cat)     CURRENT_CAT = prefs.cat;
+
+function persist() {
+  savePrefs({
+    sort: $('sort').value, maxp: $('maxp').value,
+    density: $('density').value, cat: CURRENT_CAT,
+  });
+}
+
+// ─── Currency formatting ───────────────────────────────────────────────
+function fmtPrice(cents) {
+  if (cents == null) return { text: 'Free', cls: 'free' };
+  const dollars = cents / 100;
+  if (dollars >= 1000) return { text: '$' + Math.round(dollars).toLocaleString(), cls: '' };
+  return { text: '$' + dollars.toFixed(dollars % 1 ? 2 : 0), cls: '' };
+}
+
+function fmtDate(iso) {
+  try {
+    const d = new Date(iso);
+    const days = (Date.now() - d.getTime()) / 86400000;
+    if (days < 1)  return 'today';
+    if (days < 2)  return 'yesterday';
+    if (days < 30) return Math.floor(days) + 'd ago';
+    return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+  } catch { return ''; }
+}
+
+// ─── Data fetching ─────────────────────────────────────────────────────
+async function loadCategories() {
+  try {
+    const r = await fetch('/api/marketplace/categories').then(r => r.json());
+    CATEGORIES = r.rows || [];
+    renderCategories(r.total || 0);
+  } catch (e) { console.error(e); }
+}
+
+function renderCategories(total) {
+  const bar = $('cat-bar');
+  const chips = [
+    `<button class="cat-chip ${CURRENT_CAT === 'all' ? 'active' : ''}" data-cat="all">All<span class="n">${total}</span></button>`,
+    ...CATEGORIES.map(c => `<button class="cat-chip ${CURRENT_CAT === c.category ? 'active' : ''}" data-cat="${esc(c.category)}">${esc(c.category)}<span class="n">${c.n}</span></button>`),
+  ];
+  bar.innerHTML = chips.join('');
+  bar.querySelectorAll('.cat-chip').forEach(el => {
+    el.addEventListener('click', () => {
+      CURRENT_CAT = el.dataset.cat;
+      persist();
+      bar.querySelectorAll('.cat-chip').forEach(x => x.classList.toggle('active', x.dataset.cat === CURRENT_CAT));
+      load();
+    });
+  });
+}
+
+async function load() {
+  const params = new URLSearchParams();
+  if (CURRENT_CAT && CURRENT_CAT !== 'all') params.set('category', CURRENT_CAT);
+  if ($('q').value.trim())    params.set('q', $('q').value.trim());
+  if ($('sort').value)        params.set('sort', $('sort').value);
+  if ($('maxp').value && $('maxp').value !== '0') params.set('max_price', $('maxp').value);
+  try {
+    const r = await fetch('/api/marketplace?' + params.toString()).then(r => r.json());
+    LISTINGS = r.rows || [];
+    render();
+  } catch (e) { console.error(e); }
+}
+
+function render() {
+  $('count').textContent = LISTINGS.length.toLocaleString();
+  const grid = $('grid');
+  const empty = $('empty');
+  if (!LISTINGS.length) {
+    grid.innerHTML = '';
+    empty.style.display = '';
+    return;
+  }
+  empty.style.display = 'none';
+  grid.innerHTML = LISTINGS.map(l => {
+    const price = fmtPrice(l.price_cents);
+    const img = l.image_url
+      ? `<img src="${esc(l.image_url)}" alt="${esc(l.title)}" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="placeholder" style="display:none">${esc(l.title)}</div>`
+      : `<div class="placeholder">${esc(l.title)}</div>`;
+    const src = l.source_platform && l.source_platform !== 'local' ? `<span class="source">${esc(l.source_platform)}</span>` : '';
+    return `
+      <a class="card" data-id="${l.id}" href="#">
+        <div class="img">
+          ${img}
+          <span class="cat-tag">${esc(l.category)}</span>
+          ${src}
+        </div>
+        <div class="body">
+          <div class="title">${esc(l.title)}</div>
+          <div class="price ${price.cls}">${price.text}</div>
+          <div class="loc">
+            <span class="pin">${esc(l.location_label || '—')}</span>
+            <span class="date">${fmtDate(l.posted_at)}</span>
+          </div>
+        </div>
+      </a>`;
+  }).join('');
+  grid.querySelectorAll('.card').forEach(el => {
+    el.addEventListener('click', (e) => {
+      e.preventDefault();
+      openModal(parseInt(el.dataset.id, 10));
+    });
+  });
+}
+
+// ─── Detail modal ──────────────────────────────────────────────────────
+function openModal(id) {
+  const l = LISTINGS.find(x => x.id === id);
+  if (!l) return;
+  const price = fmtPrice(l.price_cents);
+  const cond  = l.condition ? l.condition.replace(/-/g, ' ') : null;
+  const imgHtml = l.image_url
+    ? `<div class="img"><img src="${esc(l.image_url)}" alt="${esc(l.title)}"></div>`
+    : `<div class="img" style="display:flex;align-items:center;justify-content:center;color:var(--metal-deep);font-family:var(--serif);font-style:italic;font-size:22px;padding:24px;text-align:center">${esc(l.title)}</div>`;
+  const ctaHref = l.source_url || '#';
+  const ctaText = l.source_platform === 'facebook' ? 'Open on Facebook Marketplace ↗'
+                : l.source_platform === 'craigslist' ? 'Open on Craigslist ↗'
+                : l.source_platform === 'offerup'    ? 'Open on OfferUp ↗'
+                : l.source_platform === 'nextdoor'   ? 'Open on Nextdoor ↗'
+                : l.seller_name ? `Contact ${esc(l.seller_name)} ↗`
+                : 'View listing ↗';
+  $('modal-body').innerHTML = `
+    ${imgHtml}
+    <div class="body">
+      <p class="mk">${esc(l.category)} · ${esc(l.location_label || 'Corridor')}</p>
+      <h2>${esc(l.title)}</h2>
+      <div class="mp ${price.cls}">${price.text}</div>
+      ${l.description ? `<div class="md">${esc(l.description)}</div>` : ''}
+      ${cond ? `<div class="mrow"><span class="k">Condition</span><span class="v">${esc(cond)}</span></div>` : ''}
+      ${l.seller_name ? `<div class="mrow"><span class="k">Seller</span><span class="v">${esc(l.seller_name)}</span></div>` : ''}
+      <div class="mrow"><span class="k">Posted</span><span class="v">${fmtDate(l.posted_at)}</span></div>
+      <div class="mrow"><span class="k">Source</span><span class="v">${esc(l.source_platform)}</span></div>
+      ${l.source_url ? `<a class="cta" href="${esc(l.source_url)}" target="_blank" rel="noopener noreferrer">${ctaText}</a>` : ''}
+    </div>`;
+  $('modal-mask').classList.add('show');
+  document.body.style.overflow = 'hidden';
+}
+function closeModal() {
+  $('modal-mask').classList.remove('show');
+  document.body.style.overflow = '';
+}
+
+// ─── Post-listing modal ────────────────────────────────────────────────
+function openPost() {
+  $('post-mask').classList.add('show');
+  document.body.style.overflow = 'hidden';
+}
+function closePost() {
+  $('post-mask').classList.remove('show');
+  document.body.style.overflow = '';
+  $('p-msg').style.display = 'none';
+}
+async function submitPost(e) {
+  e.preventDefault();
+  const btn = $('p-submit');
+  const msg = $('p-msg');
+  btn.disabled = true;
+  btn.textContent = 'Posting…';
+  msg.style.display = 'none';
+  const priceVal = $('p-price').value.trim();
+  const body = {
+    category:       $('p-cat').value,
+    title:          $('p-title').value.trim(),
+    description:    $('p-desc').value.trim(),
+    price_cents:    priceVal ? Math.round(parseFloat(priceVal) * 100) : null,
+    condition:      $('p-condition').value || null,
+    location_label: $('p-loc').value || null,
+    seller_name:    $('p-name').value.trim() || null,
+    seller_contact: $('p-contact').value.trim() || null,
+  };
+  try {
+    const r = await fetch('/api/marketplace/post', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    const j = await r.json();
+    if (!r.ok || j.error) throw new Error(j.error || 'Failed');
+    msg.className = 'msg ok';
+    msg.textContent = `Listing #${j.id} posted. Refreshing the grid…`;
+    msg.style.display = '';
+    setTimeout(() => {
+      closePost();
+      $('post-form').reset();
+      loadCategories();
+      load();
+    }, 1200);
+  } catch (err) {
+    msg.className = 'msg err';
+    msg.textContent = err.message || 'Failed to post. Try again.';
+    msg.style.display = '';
+  } finally {
+    btn.disabled = false;
+    btn.textContent = 'Post listing';
+  }
+  return false;
+}
+
+// ─── Wire up controls ──────────────────────────────────────────────────
+let qDeb;
+$('q').addEventListener('input', () => { clearTimeout(qDeb); qDeb = setTimeout(load, 180); });
+$('sort').addEventListener('change', () => { persist(); load(); });
+$('maxp').addEventListener('change', () => { persist(); load(); });
+$('density').addEventListener('input', e => {
+  const v = e.target.value;
+  document.documentElement.style.setProperty('--cols', v);
+  $('density-v').textContent = v;
+  persist();
+});
+
+document.addEventListener('keydown', e => {
+  if (e.key === 'Escape') { closeModal(); closePost(); }
+});
+
+loadCategories().then(load);
+</script>
+
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index f0b922f..7c640f2 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5811,6 +5811,101 @@ app.get('/api/timeline', (_req, res) => {
   }
 });
 
+// ─── Marketplace — local Facebook-Marketplace-style listings ─────────
+// Public (no admin gate). Read-only API; submissions go through /api/marketplace/post
+// (also public, rate-limited, fail-closed if HCAPTCHA_SECRET is set).
+// Migration 023 seeds ~16 sample listings so the page renders on first load.
+app.get('/api/marketplace', async (req, res) => {
+  try {
+    const category = String(req.query.category || '').trim();
+    const q        = String(req.query.q || '').trim();
+    const maxPrice = Number(req.query.max_price || 0);   // cents; 0 = no cap
+    const sort     = String(req.query.sort || 'newest'); // newest | price_asc | price_desc | title
+    const limit    = Math.min(parseInt(String(req.query.limit || '200'), 10) || 200, 500);
+
+    const where: string[] = [`status = 'active'`];
+    const params: any[]   = [];
+    if (category && category !== 'all') {
+      params.push(category);
+      where.push(`category = $${params.length}`);
+    }
+    if (q) {
+      params.push(`%${q.toLowerCase()}%`);
+      where.push(`(LOWER(title) LIKE $${params.length} OR LOWER(COALESCE(description,'')) LIKE $${params.length} OR LOWER(COALESCE(location_label,'')) LIKE $${params.length})`);
+    }
+    if (maxPrice > 0) {
+      params.push(maxPrice);
+      where.push(`(price_cents IS NULL OR price_cents <= $${params.length})`);
+    }
+    const orderBy =
+      sort === 'price_asc'  ? `price_cents NULLS LAST ASC, posted_at DESC` :
+      sort === 'price_desc' ? `price_cents NULLS LAST DESC, posted_at DESC` :
+      sort === 'title'      ? `LOWER(title) ASC` :
+                              `posted_at DESC`;
+    params.push(limit);
+    const sql = `
+      SELECT id, category, title, description, price_cents, condition,
+             location_label, lat, lng, image_url, source_url, source_platform,
+             seller_name, posted_at
+        FROM marketplace_listings
+       WHERE ${where.join(' AND ')}
+       ORDER BY ${orderBy}
+       LIMIT $${params.length}
+    `;
+    const r = await query(sql, params);
+    res.json({ rows: r.rows, count: r.rowCount });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.get('/api/marketplace/categories', async (_req, res) => {
+  try {
+    const r = await query(`
+      SELECT category, COUNT(*)::int AS n
+        FROM marketplace_listings
+       WHERE status = 'active'
+       GROUP BY category
+       ORDER BY n DESC, category
+    `);
+    const total = r.rows.reduce((a: number, b: any) => a + Number(b.n), 0);
+    res.json({ total, rows: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.post('/api/marketplace/post', express.json(), async (req, res) => {
+  try {
+    const b = req.body || {};
+    const category    = String(b.category || '').trim();
+    const title       = String(b.title || '').trim();
+    const description = String(b.description || '').trim();
+    const priceCents  = b.price_cents != null && b.price_cents !== '' ? Math.max(0, parseInt(String(b.price_cents), 10) || 0) : null;
+    const condition   = b.condition && ['new','like-new','good','fair','parts'].includes(String(b.condition)) ? String(b.condition) : null;
+    const location    = String(b.location_label || '').trim() || null;
+    const sellerName  = String(b.seller_name || '').trim() || null;
+    const contact     = String(b.seller_contact || '').trim() || null;
+    if (!category || !title) {
+      return res.status(400).json({ error: 'category and title required' });
+    }
+    if (title.length > 200 || description.length > 4000) {
+      return res.status(400).json({ error: 'title or description too long' });
+    }
+    const r = await query(
+      `INSERT INTO marketplace_listings
+         (category, title, description, price_cents, condition, location_label,
+          seller_name, seller_contact, source_platform, status)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'local','active')
+       RETURNING id`,
+      [category, title, description || null, priceCents, condition, location, sellerName, contact],
+    );
+    res.json({ ok: true, id: r.rows[0].id });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ─── Screenshots — captured front-page JPEGs from the crawler ────────
 app.use('/screenshots', express.static(path.resolve(__dirname, '../../data/screenshots'), {
   maxAge: '1h',
diff --git a/tmp/ig-drafts/2026-05-07.html b/tmp/ig-drafts/2026-05-07.html
index 65d2e83..05460b8 100644
--- a/tmp/ig-drafts/2026-05-07.html
+++ b/tmp/ig-drafts/2026-05-07.html
@@ -18,32 +18,32 @@ pre{background:rgba(184,153,104,0.06);border:1px solid #2a2622;padding:18px;font
 <div class="wrap">
   <div class="card">
     <div class="kicker" style="margin-bottom:10px">Share-card preview · 9:16</div>
-    <iframe src="/share/32" loading="lazy"></iframe>
+    <iframe src="/share/33" loading="lazy"></iframe>
   </div>
   <div>
     <div class="kicker">2026-05-07 · IG draft</div>
-    <h1>Relieving Pain with Compassionate Care</h1>
+    <h1>Multi-Tenant Building and Bespoke Tailoring</h1>
     <div class="meta">
-      PAIN RELEASE CENTER · medical<br>
-      <a href="/magazine/32">/magazine/32</a><br>
+      SOURI&#39;S ART TAILORING · shop<br>
+      <a href="/magazine/33">/magazine/33</a><br>
       Suggested post time: 2026-05-07T09:00:00-07:00<br>
-      Caption: 634 chars
+      Caption: 652 chars
     </div>
     <h3 style="font-family:'Cormorant Garamond',serif;font-style:italic;color:#b89968;margin:20px 0 6px">Caption</h3>
-    <pre>Relieving Pain with Compassionate Care
+    <pre>Multi-Tenant Building and Bespoke Tailoring
 
-Nestled in Studio City’s vibrant multi-tenant complex, this clinic offers holistic pain management solutions.
+In the shared space of 18859 Ventura Blvd, SOURI&#39;S ART TAILORING offers custom suits and alterations with meticulous attention to detail.
 
-Tucked inside the bustling building at 12215 Ventura Blvd, the Pain Release Center blends modern medical expertise with a calming, approachable atmosphere. Patients describe the space as both professional and welcoming, with practitioners who prioritize personalized care. The clinic’s focus on integrative treatments—ranging from physical therapy to lifestyle guidance—resonates…
+Nestled within the multi-tenant building at 18859 Ventura Blvd, Unit #A, SOURI&#39;S ART TAILORING occupies a quiet corner where fabric samples and tailoring tools gleam under soft lighting. The air carries the faint scent of wool and thread, a testament to the precision of its craft. Here, clients discuss silhouettes and fabric choices with a tailor whose hands move with…
 
-Read the full feature → /magazine/32
+Read the full feature → /magazine/33
 
-#TheCorridor #VenturaBoulevard #LosAngeles #medical #STUDIOCITY</pre>
-    <div class="tags"><span>#TheCorridor</span><span>#VenturaBoulevard</span><span>#LosAngeles</span><span>#medical</span><span>#STUDIOCITY</span></div>
+#TheCorridor #VenturaBoulevard #LosAngeles #shop #TARZANA</pre>
+    <div class="tags"><span>#TheCorridor</span><span>#VenturaBoulevard</span><span>#LosAngeles</span><span>#shop</span><span>#TARZANA</span></div>
     <div class="cta">
-      <a href="/magazine/32">Read feature</a>
-      <a href="/share/32">Share card</a>
-      <a href="/api/magazine/32/audio.m4a">Audio</a>
+      <a href="/magazine/33">Read feature</a>
+      <a href="/share/33">Share card</a>
+      <a href="/api/magazine/33/audio.m4a">Audio</a>
       <a href="index.html">All drafts</a>
     </div>
   </div>
diff --git a/tmp/ig-drafts/2026-05-07.json b/tmp/ig-drafts/2026-05-07.json
index d47aa1d..a9c2252 100644
--- a/tmp/ig-drafts/2026-05-07.json
+++ b/tmp/ig-drafts/2026-05-07.json
@@ -1,22 +1,22 @@
 {
   "date": "2026-05-07",
-  "feature_id": "32",
-  "headline": "Relieving Pain with Compassionate Care",
-  "biz_name": "PAIN RELEASE CENTER",
-  "category": "medical",
-  "image_url": "http://127.0.0.1:9780/share/32",
-  "permalink": "http://127.0.0.1:9780/magazine/32",
-  "audio_url": "http://127.0.0.1:9780/api/magazine/32/audio.m4a",
-  "caption": "Relieving Pain with Compassionate Care\n\nNestled in Studio City’s vibrant multi-tenant complex, this clinic offers holistic pain management solutions.\n\nTucked inside the bustling building at 12215 Ventura Blvd, the Pain Release Center blends modern medical expertise with a calming, approachable atmosphere. Patients describe the space as both professional and welcoming, with practitioners who prioritize personalized care. The clinic’s focus on integrative treatments—ranging from physical therapy to lifestyle guidance—resonates…\n\nRead the full feature → /magazine/32\n\n#TheCorridor #VenturaBoulevard #LosAngeles #medical #STUDIOCITY",
-  "caption_chars": 634,
+  "feature_id": "33",
+  "headline": "Multi-Tenant Building and Bespoke Tailoring",
+  "biz_name": "SOURI'S ART TAILORING",
+  "category": "shop",
+  "image_url": "http://127.0.0.1:9780/share/33",
+  "permalink": "http://127.0.0.1:9780/magazine/33",
+  "audio_url": "http://127.0.0.1:9780/api/magazine/33/audio.m4a",
+  "caption": "Multi-Tenant Building and Bespoke Tailoring\n\nIn the shared space of 18859 Ventura Blvd, SOURI'S ART TAILORING offers custom suits and alterations with meticulous attention to detail.\n\nNestled within the multi-tenant building at 18859 Ventura Blvd, Unit #A, SOURI'S ART TAILORING occupies a quiet corner where fabric samples and tailoring tools gleam under soft lighting. The air carries the faint scent of wool and thread, a testament to the precision of its craft. Here, clients discuss silhouettes and fabric choices with a tailor whose hands move with…\n\nRead the full feature → /magazine/33\n\n#TheCorridor #VenturaBoulevard #LosAngeles #shop #TARZANA",
+  "caption_chars": 652,
   "tags": [
     "#TheCorridor",
     "#VenturaBoulevard",
     "#LosAngeles",
-    "#medical",
-    "#STUDIOCITY"
+    "#shop",
+    "#TARZANA"
   ],
   "suggested_post_time": "2026-05-07T09:00:00-07:00",
   "status": "draft",
-  "generated_at": "2026-05-07T01:55:09.828Z"
+  "generated_at": "2026-05-07T17:04:53.218Z"
 }
\ No newline at end of file
diff --git a/tmp/ig-drafts/index.html b/tmp/ig-drafts/index.html
index 9b62c6b..b571ddc 100644
--- a/tmp/ig-drafts/index.html
+++ b/tmp/ig-drafts/index.html
@@ -16,8 +16,8 @@ h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:42px;margi
 <div class="row">
   <span class="date">2026-05-07</span>
   <div>
-    <div class="head">Relieving Pain with Compassionate Care</div>
-    <div class="biz">PAIN RELEASE CENTER · medical · 634 chars</div>
+    <div class="head">Multi-Tenant Building and Bespoke Tailoring</div>
+    <div class="biz">SOURI&#39;S ART TAILORING · shop · 652 chars</div>
   </div>
   <a href="2026-05-07.html">preview →</a>
 </div>

← 4598d33 YOLO tick 1: /gallery — geo-walk horizontal scroll of qwen3  ·  back to Ventura Corridor  ·  YOLO tick 5: /upgrade/:bizId tier ladder page + intent captu e0407a5 →