[object Object]

← back to Abrams

v0.5: Mag-20 audit page + full gamify layer

aabfbe7a6a4749331616be3a7b3c89b136e29c77 · 2026-05-14 09:23:41 -0700 · Steve Abrams

mag-20:
- data/mag-20.json codifies top 20 standing-rule patterns from MEMORY.md
- scripts/mag-20-audit.js runs each pattern against every project (13 projects × 20 rules)
- /mag-20 page: live pass/fail matrix, 6 summary cards, vertical project headers
- /api/mag-20 endpoint, hooked into tick.sh

gamify:
- public/js/gamify.js — XP/level/badge/streak/confetti engine, drop-in
- floating HUD bottom-right (level, XP, streak, badges, sound toggle)
- 14 badges (First Step, Curious Mind, Thesis Reader, Mag-20 Auditor, First Blood, Wall Poster, Chatter, TM Investigator, Tick Witness, 3-Day Streak, Week Streak, High Roller, Panel Master, SWOT Scholar)
- WebAudio beeps + level-up chord + canvas-free confetti
- /api/gamify/event POST + /api/gamify/leaderboard GET
- leaderboard section on home, top-15 with 'you' highlight
- auto-detects panel/swot/wall/chat/mag-20/trademark/lead/wall-post/chat-msg/project-view actions
- streak tracking persisted to localStorage
- corner-nav drawer adds Thesis, Mag-20 Audit, Press Kit, Leaderboard links

Files touched

Diff

commit aabfbe7a6a4749331616be3a7b3c89b136e29c77
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 09:23:41 2026 -0700

    v0.5: Mag-20 audit page + full gamify layer
    
    mag-20:
    - data/mag-20.json codifies top 20 standing-rule patterns from MEMORY.md
    - scripts/mag-20-audit.js runs each pattern against every project (13 projects × 20 rules)
    - /mag-20 page: live pass/fail matrix, 6 summary cards, vertical project headers
    - /api/mag-20 endpoint, hooked into tick.sh
    
    gamify:
    - public/js/gamify.js — XP/level/badge/streak/confetti engine, drop-in
    - floating HUD bottom-right (level, XP, streak, badges, sound toggle)
    - 14 badges (First Step, Curious Mind, Thesis Reader, Mag-20 Auditor, First Blood, Wall Poster, Chatter, TM Investigator, Tick Witness, 3-Day Streak, Week Streak, High Roller, Panel Master, SWOT Scholar)
    - WebAudio beeps + level-up chord + canvas-free confetti
    - /api/gamify/event POST + /api/gamify/leaderboard GET
    - leaderboard section on home, top-15 with 'you' highlight
    - auto-detects panel/swot/wall/chat/mag-20/trademark/lead/wall-post/chat-msg/project-view actions
    - streak tracking persisted to localStorage
    - corner-nav drawer adds Thesis, Mag-20 Audit, Press Kit, Leaderboard links
---
 data/build-log.jsonl     |    5 +
 data/gamify-events.jsonl |    0
 data/ideas.json          |   57 ++-
 data/mag-20-results.json | 1277 ++++++++++++++++++++++++++++++++++++++++++++++
 data/mag-20.json         |  167 ++++++
 data/projects.json       |   14 +-
 public/chat.html         |    1 +
 public/ideas.html        |    1 +
 public/index.html        |   43 +-
 public/js/corner-nav.js  |    3 +
 public/js/gamify.js      |  247 +++++++++
 public/mag-20.html       |  113 ++++
 public/trademark.html    |    1 +
 public/wall.html         |    1 +
 scripts/mag-20-audit.js  |  168 ++++++
 scripts/tick.sh          |    9 +-
 server.js                |   35 ++
 17 files changed, 2135 insertions(+), 7 deletions(-)

diff --git a/data/build-log.jsonl b/data/build-log.jsonl
index 750f19f5..333ad95e 100644
--- a/data/build-log.jsonl
+++ b/data/build-log.jsonl
@@ -48,3 +48,8 @@
 {"ts":"2026-05-14T16:17:05Z","type":"inventory-ok","phase":"inventory"}
 {"ts":"2026-05-14T16:17:06.307Z","type":"swot","slug":"agentabrams","name":"AgentAbrams"}
 {"ts":"2026-05-14T16:17:05Z","type":"swot-ok","phase":"swot"}
+{"ts":"2026-05-14T16:17:05Z","type":"git-commit","phase":"git"}
+{"ts":"2026-05-14T16:17:06Z","type":"drain-queue","drained":0,"failed":2,"pending":2}
+{"ts":"2026-05-14T16:17:05Z","type":"tick-done","tick":"1778775426"}
+{"ts":"2026-05-14T16:20:54.402Z","type":"mag-20-audit","projects":13,"total_pass":72,"total_fail":32}
+{"ts":"2026-05-14T16:23:20.695Z","type":"server-up","port":9898,"bind":"0.0.0.0"}
diff --git a/data/gamify-events.jsonl b/data/gamify-events.jsonl
new file mode 100644
index 00000000..e69de29b
diff --git a/data/ideas.json b/data/ideas.json
index 38e37523..5fa0936d 100644
--- a/data/ideas.json
+++ b/data/ideas.json
@@ -510,8 +510,61 @@
         "Schedule weekly Friday-recap email."
       ],
       "generated_at": "2026-05-14T16:17:06.306Z"
+    },
+    {
+      "slug": "wallco-ai",
+      "name": "wallco.ai",
+      "tagline": "AI is the new wallpaper press.",
+      "summary": "wallco.ai — AI-generated seamless wallpaper patterns. Replicate SDXL → settlement-gated pre-publish → Spoonflower upload → Shopify draft. Includes the wallco Marketplace (Patternbank × Material Bank × Spoonflower clone) with designer commissions, badges, follower system, leaderboards. ~4,250 published designs, surprise-me chat agent (qwen3:14b on Mac1 via Tailscale), 1×1 mural pan/zoom/crop editor, post-gen vision check.\n\nAI is the new wallpaper press.",
+      "stage": "live",
+      "category": "ai-design",
+      "domain": "wallco.ai",
+      "port": 9905,
+      "strengths": [
+        "End-to-end pipeline shipped: text prompt → settlement gate → Replicate SDXL → palette extract → DB persist → Spoonflower upload → Shopify draft (idempotent).",
+        "Settlement legal moat — 7 SHA-locked sub-skills + binding text vault + post-gen vision check make this the only AI-pattern generator that's safe to publish at scale under the executed banana-leaf settlement.",
+        "Marketplace gives Steve the Patternbank × Material Bank × Spoonflower clone — designer commissions, 7 levels, 18 badges, follower graph, payout ledger.",
+        "Cost structure dominated by local LLMs (Mac1 + Mac2 ollama unmetered) — only Replicate ($) is per-design.",
+        "~4,250 designs already published — content moat compounds with every YOLO tick."
+      ],
+      "weaknesses": [
+        "Single-machine pipeline (Mac2 + Kamatera) — bus factor 1, no DR.",
+        "Replicate spend grows linearly with design count and has no hedge against price hikes.",
+        "Surprise-me / chat handler silently dropped DB ids until 2026-05-14 fix — psql RETURNING swallowed → downstream interior-designer review broken; suggests other silent-failure paths in the pipeline.",
+        "data/generated/ is 12 GB and grows daily — every deploy.sh tries to push it because the script has no data/ exclude, which lets concurrent rsyncs race + corrupt with --delete.",
+        "Marketplace has 5 fictional founding designers — needs 50 real ones before the network effect kicks in."
+      ],
+      "opportunities": [
+        "Pattern packs to interior designers as commission-free downloadable bundles ($49 / 12 patterns).",
+        "License the settlement-gate skill to other AI-design tools as a white-label compliance layer ($499/mo).",
+        "Spoonflower commission — every pattern sold via the wallco→Spoonflower bridge generates passive royalties.",
+        "Print-on-demand mural / fabric tier — 1×1 mural editor is already shipped, monetization is one Stripe checkout away.",
+        "Designer-marketplace creator-economy play — top 10 patternmakers earn $X/mo, becomes a recruiting magnet for the next 100.",
+        "AI-design API — let other wallcovering / fabric / paper brands hit a private endpoint for custom patterns."
+      ],
+      "threats": [
+        "Settlement violation = lawsuit risk — even with the gate, one slipped banana-leaf design shipping publicly could re-trigger.",
+        "Replicate pricing change or model deprecation kills the SDXL backbone overnight.",
+        "Adobe Firefly / Midjourney could ship a 'seamless tile' mode and undercut the prompt-→-pattern UX.",
+        "Patternbank / Material Bank could clone the marketplace tier without the AI generation layer and outpace on supply.",
+        "Spoonflower API access revoked = the consumer-purchase loop breaks."
+      ],
+      "revenue_lines": [
+        "Pattern packs @ $49 × 200/mo = $9.8k MRR.",
+        "Settlement-gate white-label @ $499/mo × 20 brands = $10k MRR.",
+        "Marketplace transaction fee — 15% of $50k GMV/mo = $7.5k MRR.",
+        "Spoonflower royalty — passive, $1–3 per yard sold, scales with backlist.",
+        "AI-design API @ $0.05 per generation × 100k/mo = $5k MRR."
+      ],
+      "competitive_moat": "Three-layer moat — (1) the SHA-locked settlement gate is the only legal compliance layer in the AI-pattern category, (2) ~4,250 already-published designs create a content compounding effect, (3) marketplace creator network effects once 50+ real designers sign on.",
+      "next_3_moves": [
+        "Fix silent psql RETURNING id swallow in src/chat.js so chat-generated designs actually persist + downstream interior-designer review renders (open from 2026-05-14 surprise-me debug).",
+        "Add data/ to ~/Projects/_shared/scripts/deploy.sh exclude list (or wire a per-project .deployignore) so deploys stop racing over 12GB of mutating PNG variants and trash each other with --delete.",
+        "Recruit 10 real founding designers for the marketplace — replace the 5 fictional seeds, target 5 IG-known wallpaper artists with a $500 signing-bonus pattern pack."
+      ],
+      "generated_at": "2026-05-14T16:18:00.000Z"
     }
   ],
-  "updated_at": "2026-05-14T16:17:06.307Z",
-  "count": 12
+  "updated_at": "2026-05-14T16:18:00.000Z",
+  "count": 13
 }
\ No newline at end of file
diff --git a/data/mag-20-results.json b/data/mag-20-results.json
new file mode 100644
index 00000000..b3622459
--- /dev/null
+++ b/data/mag-20-results.json
@@ -0,0 +1,1277 @@
+{
+  "generated_at": "2026-05-14T16:20:53.306Z",
+  "projects": [
+    {
+      "slug": "wallco-ai",
+      "name": "wallco.ai",
+      "status": "live",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no index.html"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "327 commits over 2 days (163.50/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "fail",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "pass",
+          "evidence": "2 limiters / 41 POSTs"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 5,
+      "fail": 1,
+      "na": 14
+    },
+    {
+      "slug": "abrams-panel",
+      "name": "Abrams Panel",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "1/6 missing chrome"
+        },
+        "2": {
+          "status": "pass",
+          "evidence": "all pages have MENU label"
+        },
+        "3": {
+          "status": "pass",
+          "evidence": "enter:true bottom-anchored:true"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "pass",
+          "evidence": "1 grid pages compliant"
+        },
+        "6": {
+          "status": "pass",
+          "evidence": "all pages have corner-nav"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "fail",
+          "evidence": "1/6 missing viewport meta"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "9 commits over 1 days (9.00/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "fail",
+          "evidence": "LEAKS: index.html: Qwen3"
+        },
+        "15": {
+          "status": "pass",
+          "evidence": "addr:true unsub:true"
+        },
+        "16": {
+          "status": "pass",
+          "evidence": "ASCII-safe subject"
+        },
+        "17": {
+          "status": "pass",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "pass",
+          "evidence": "7 limiters / 3 POSTs"
+        },
+        "19": {
+          "status": "pass",
+          "evidence": "HOUR-gate:true hour-bounds:true"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 13,
+      "fail": 3,
+      "na": 4
+    },
+    {
+      "slug": "4square-agentabrams",
+      "name": "4square-agentabrams",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "1/1 missing chrome"
+        },
+        "2": {
+          "status": "fail",
+          "evidence": "1/1 missing MENU label"
+        },
+        "3": {
+          "status": "fail",
+          "evidence": "enter:false bottom-anchored:false"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "fail",
+          "evidence": "1/1 grid pages missing sort/density"
+        },
+        "6": {
+          "status": "fail",
+          "evidence": "1/1 missing corner-nav.js"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "pass",
+          "evidence": "all pages have viewport"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "fail",
+          "evidence": "2 commits over 1 days (2.00/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "pass",
+          "evidence": "no vendor leaks"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "pass",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no POST routes"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 6,
+      "fail": 6,
+      "na": 8
+    },
+    {
+      "slug": "abrams",
+      "name": "Abrams",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "1/6 missing chrome"
+        },
+        "2": {
+          "status": "pass",
+          "evidence": "all pages have MENU label"
+        },
+        "3": {
+          "status": "pass",
+          "evidence": "enter:true bottom-anchored:true"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "pass",
+          "evidence": "1 grid pages compliant"
+        },
+        "6": {
+          "status": "pass",
+          "evidence": "all pages have corner-nav"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "fail",
+          "evidence": "1/6 missing viewport meta"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "9 commits over 1 days (9.00/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "fail",
+          "evidence": "LEAKS: index.html: Qwen3"
+        },
+        "15": {
+          "status": "pass",
+          "evidence": "addr:true unsub:true"
+        },
+        "16": {
+          "status": "pass",
+          "evidence": "ASCII-safe subject"
+        },
+        "17": {
+          "status": "pass",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "pass",
+          "evidence": "7 limiters / 3 POSTs"
+        },
+        "19": {
+          "status": "pass",
+          "evidence": "HOUR-gate:true hour-bounds:true"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 13,
+      "fail": 3,
+      "na": 4
+    },
+    {
+      "slug": "abramsos",
+      "name": "AbramsOS",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no index.html"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "22 commits over 4 days (5.50/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "fail",
+          "evidence": "no listen() found"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no POST routes"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 4,
+      "fail": 1,
+      "na": 15
+    },
+    {
+      "slug": "agentabrams",
+      "name": "AgentAbrams",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no index.html"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "fail",
+          "evidence": "2 commits over 7 days (0.29/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 3,
+      "fail": 1,
+      "na": 16
+    },
+    {
+      "slug": "abrams-discovery-viewer",
+      "name": "abrams-discovery-viewer",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "1/1 missing chrome"
+        },
+        "2": {
+          "status": "fail",
+          "evidence": "1/1 missing MENU label"
+        },
+        "3": {
+          "status": "fail",
+          "evidence": "enter:false bottom-anchored:false"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "fail",
+          "evidence": "1/1 missing corner-nav.js"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "fail",
+          "evidence": "1/1 missing viewport meta"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "4 commits over 7 days (0.57/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "pass",
+          "evidence": "no vendor leaks"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "pass",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "fail",
+          "evidence": "0 limiters / 1 POSTs"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 6,
+      "fail": 6,
+      "na": 8
+    },
+    {
+      "slug": "abrams-report",
+      "name": "abrams-report",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no index.html"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "8 commits over 2 days (4.00/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "fail",
+          "evidence": "no listen() found"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no POST routes"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 4,
+      "fail": 1,
+      "na": 15
+    },
+    {
+      "slug": "mp3-agentabrams",
+      "name": "mp3-agentabrams",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "1/1 missing chrome"
+        },
+        "2": {
+          "status": "fail",
+          "evidence": "1/1 missing MENU label"
+        },
+        "3": {
+          "status": "fail",
+          "evidence": "enter:false bottom-anchored:false"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "pass",
+          "evidence": "1 grid pages compliant"
+        },
+        "6": {
+          "status": "fail",
+          "evidence": "1/1 missing corner-nav.js"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "pass",
+          "evidence": "all pages have viewport"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "3 commits over 1 days (3.00/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "pass",
+          "evidence": "no vendor leaks"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "pass",
+          "evidence": "127.0.0.1 default"
+        },
+        "18": {
+          "status": "fail",
+          "evidence": "0 limiters / 1 POSTs"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 8,
+      "fail": 5,
+      "na": 7
+    },
+    {
+      "slug": "videos-agentabrams-worker",
+      "name": "videos-agentabrams-worker",
+      "status": "dev",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no index.html"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no grid pages"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "4 commits over 7 days (0.57/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no html files"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 4,
+      "fail": 0,
+      "na": 16
+    },
+    {
+      "slug": "lawyers-agentabrams",
+      "name": "lawyers.agentabrams.com",
+      "status": "live",
+      "checks": {
+        "1": {
+          "status": "fail",
+          "evidence": "7/7 missing chrome"
+        },
+        "2": {
+          "status": "fail",
+          "evidence": "7/7 missing MENU label"
+        },
+        "3": {
+          "status": "fail",
+          "evidence": "enter:false bottom-anchored:false"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "rotating hero rule is for DW microsites, not panels"
+        },
+        "5": {
+          "status": "fail",
+          "evidence": "2/2 grid pages missing sort/density"
+        },
+        "6": {
+          "status": "fail",
+          "evidence": "7/7 missing corner-nav.js"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no contact modal trigger"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "chip rule applies to DW catalog grids"
+        },
+        "10": {
+          "status": "pass",
+          "evidence": "all pages have viewport"
+        },
+        "11": {
+          "status": "pass",
+          "evidence": ".git/ present"
+        },
+        "12": {
+          "status": "pass",
+          "evidence": "35 commits over 14 days (2.50/day)"
+        },
+        "13": {
+          "status": "pass",
+          "evidence": "all 5 essentials present"
+        },
+        "14": {
+          "status": "pass",
+          "evidence": "no vendor leaks"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no mailer in this project"
+        },
+        "17": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no server.js"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no hourly mailer in this project"
+        },
+        "20": {
+          "status": "pass",
+          "evidence": "259 what-landed videos exist; per-project mapping TBD"
+        }
+      },
+      "score": 6,
+      "fail": 5,
+      "na": 9
+    },
+    {
+      "slug": "sdcc-agentabrams",
+      "name": "sdccanalysis.agentabrams.com",
+      "status": "live",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "11": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "12": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "13": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "17": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "20": {
+          "status": "n/a",
+          "evidence": "no local path"
+        }
+      },
+      "score": 0,
+      "fail": 0,
+      "na": 20
+    },
+    {
+      "slug": "butlr-agentabrams",
+      "name": "butlr.agentabrams.com",
+      "status": "live",
+      "checks": {
+        "1": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "2": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "3": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "4": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "5": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "6": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "7": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "8": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "9": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "10": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "11": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "12": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "13": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "14": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "15": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "16": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "17": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "18": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "19": {
+          "status": "n/a",
+          "evidence": "no local path"
+        },
+        "20": {
+          "status": "n/a",
+          "evidence": "no local path"
+        }
+      },
+      "score": 0,
+      "fail": 0,
+      "na": 20
+    }
+  ],
+  "summary": {
+    "1": {
+      "pass": 0,
+      "fail": 6,
+      "na": 7
+    },
+    "2": {
+      "pass": 2,
+      "fail": 4,
+      "na": 7
+    },
+    "3": {
+      "pass": 2,
+      "fail": 4,
+      "na": 7
+    },
+    "4": {
+      "pass": 0,
+      "fail": 0,
+      "na": 13
+    },
+    "5": {
+      "pass": 3,
+      "fail": 2,
+      "na": 8
+    },
+    "6": {
+      "pass": 2,
+      "fail": 4,
+      "na": 7
+    },
+    "7": {
+      "pass": 0,
+      "fail": 0,
+      "na": 13
+    },
+    "8": {
+      "pass": 0,
+      "fail": 0,
+      "na": 13
+    },
+    "9": {
+      "pass": 0,
+      "fail": 0,
+      "na": 13
+    },
+    "10": {
+      "pass": 3,
+      "fail": 3,
+      "na": 7
+    },
+    "11": {
+      "pass": 11,
+      "fail": 0,
+      "na": 2
+    },
+    "12": {
+      "pass": 9,
+      "fail": 2,
+      "na": 2
+    },
+    "13": {
+      "pass": 11,
+      "fail": 0,
+      "na": 2
+    },
+    "14": {
+      "pass": 4,
+      "fail": 2,
+      "na": 7
+    },
+    "15": {
+      "pass": 2,
+      "fail": 0,
+      "na": 11
+    },
+    "16": {
+      "pass": 2,
+      "fail": 0,
+      "na": 11
+    },
+    "17": {
+      "pass": 5,
+      "fail": 3,
+      "na": 5
+    },
+    "18": {
+      "pass": 3,
+      "fail": 2,
+      "na": 8
+    },
+    "19": {
+      "pass": 2,
+      "fail": 0,
+      "na": 11
+    },
+    "20": {
+      "pass": 11,
+      "fail": 0,
+      "na": 2
+    }
+  }
+}
\ No newline at end of file
diff --git a/data/mag-20.json b/data/mag-20.json
new file mode 100644
index 00000000..555dce41
--- /dev/null
+++ b/data/mag-20.json
@@ -0,0 +1,167 @@
+{
+  "title": "Mag-20 — Steve's top 20 standing-rule patterns",
+  "description": "The 20 best-practice patterns every Abrams / DW-family / agentabrams.com property MUST comply with. Auditor runs nightly and renders the live matrix at /mag-20. Sourced from MEMORY.md feedback entries + CLAUDE.md standing rules.",
+  "updated_at": "2026-05-14",
+  "patterns": [
+    {
+      "id": 1,
+      "rule": "Logo UL + hamburger UR on every page",
+      "source": "feedback_logo_ul_hamburger_ur_all_tabs.md",
+      "severity": "high",
+      "check": "grep .chrome .logo + .nav-burger in every public/*.html",
+      "category": "chrome"
+    },
+    {
+      "id": 2,
+      "rule": "Hamburger = Gucci '≡ MENU' style (3 bars + uppercase MENU label, top-right fixed)",
+      "source": "feedback_hamburger_gucci_menu_style.md",
+      "severity": "high",
+      "check": "grep '\\bMENU\\b' in nav-burger of every public/*.html",
+      "category": "chrome"
+    },
+    {
+      "id": 3,
+      "rule": "Hero CTA = single white pill ENTER button centered at bottom of hero",
+      "source": "feedback_hero_cta_enter_centered_bottom.md",
+      "severity": "high",
+      "check": "grep -E 'class=\"cta\"[^>]*>ENTER' in index.html; CSS bottom: anchor present",
+      "category": "hero"
+    },
+    {
+      "id": 4,
+      "rule": "Rotating hero OR 4-square grid (≥1500px long edge, ≥200KB) — except TSD",
+      "source": "feedback_rotating_hero_universal_except_tsd.md",
+      "severity": "medium",
+      "check": "n/a for Abrams Panel (wordmark hero is acceptable); applies to DW microsites",
+      "category": "hero"
+    },
+    {
+      "id": 5,
+      "rule": "Sort + density slider on every product grid",
+      "source": "CLAUDE.md sort+density block",
+      "severity": "high",
+      "check": "grep -E '<select id=\"sort\"|range.*density' in every grid-bearing public/*.html",
+      "category": "grid"
+    },
+    {
+      "id": 6,
+      "rule": "corner-nav.js wired on every page",
+      "source": "feedback_corner_nav_universal.md",
+      "severity": "high",
+      "check": "grep 'corner-nav.js' in every public/*.html",
+      "category": "chrome"
+    },
+    {
+      "id": 7,
+      "rule": "ns-modal CSS + nsContactOpen() function when contact modal is present",
+      "source": "feedback_ns_modal_css_missing_fanout.md",
+      "severity": "low",
+      "check": "if nsContactOpen() used, .ns-modal CSS must exist",
+      "category": "modal"
+    },
+    {
+      "id": 8,
+      "rule": "Customer-facing chat = lower-LEFT, woman avatar, never reveal vendor names",
+      "source": "feedback_bigred_customer_chat_universal.md",
+      "severity": "medium",
+      "check": "chat widget position bottom-left; SVG silhouette fallback; no AI vendor leaks",
+      "category": "chat"
+    },
+    {
+      "id": 9,
+      "rule": "Chip click → full-details modal (vendor scrubbed, says 'Designer Wallcoverings · SKU')",
+      "source": "feedback_dw_chip_click_full_details.md",
+      "severity": "medium",
+      "check": "n/a for Abrams Panel (no chip tiles); applies to DW catalog grids",
+      "category": "grid"
+    },
+    {
+      "id": 10,
+      "rule": "Viewport meta tag required for @media queries to fire on mobile",
+      "source": "feedback_viewport_meta_required_for_media_queries.md",
+      "severity": "high",
+      "check": "grep '<meta name=\"viewport\"' in every public/*.html",
+      "category": "responsive"
+    },
+    {
+      "id": 11,
+      "rule": "All projects under ~/Projects/ must be git repos (gitify rule)",
+      "source": "CLAUDE.md gitify block",
+      "severity": "high",
+      "check": "test -d <project>/.git",
+      "category": "git"
+    },
+    {
+      "id": 12,
+      "rule": "Commit after every success (one logical change = one commit)",
+      "source": "CLAUDE.md gitify block",
+      "severity": "medium",
+      "check": "git log shows commits per shipped feature (not daily-mega-commit)",
+      "category": "git"
+    },
+    {
+      "id": 13,
+      "rule": ".gitignore must exclude node_modules/, .env*, tmp/, *.log, .DS_Store, dist/, build/, .next/",
+      "source": "CLAUDE.md gitify block",
+      "severity": "high",
+      "check": "grep -c -E '(node_modules|\\.env|tmp/|\\*\\.log|\\.DS_Store|dist/|build/|\\.next/)' .gitignore == 7+",
+      "category": "git"
+    },
+    {
+      "id": 14,
+      "rule": "Never expose AI vendor names (Replicate/SDXL/Gemini/Ollama) to users",
+      "source": "feedback_never_expose_ai_software.md",
+      "severity": "critical",
+      "check": "grep -iE '(replicate|sdxl|gemini|claude-3|gpt-4|ollama|moonshot|kimi)' public/ should return 0 user-visible hits",
+      "category": "compliance"
+    },
+    {
+      "id": 15,
+      "rule": "Email mailers must include physical mailing address + unsubscribe (CAN-SPAM)",
+      "source": "comms-compliance",
+      "severity": "critical",
+      "check": "every email body contains physical address + STOP/unsubscribe before going to any external recipient",
+      "category": "compliance"
+    },
+    {
+      "id": 16,
+      "rule": "Subjects must NOT contain raw em-dash without RFC 2047 encoding (mojibake guard)",
+      "source": "feedback_email_no_mojibake.md",
+      "severity": "low",
+      "check": "grep '—' in email-hourly.sh subject line should be empty",
+      "category": "compliance"
+    },
+    {
+      "id": 17,
+      "rule": "Bind to 127.0.0.1 by default (internal panels never 0.0.0.0 unless explicit)",
+      "source": "security-auditor best-practices",
+      "severity": "high",
+      "check": "server.js app.listen uses BIND env-var defaulting to 127.0.0.1",
+      "category": "security"
+    },
+    {
+      "id": 18,
+      "rule": "Rate-limit any write endpoint (POST /api/*) — express-rate-limit on lead, wall, etc.",
+      "source": "security-auditor best-practices",
+      "severity": "high",
+      "check": "grep 'rateLimit' in server.js; each POST route has a limiter",
+      "category": "security"
+    },
+    {
+      "id": 19,
+      "rule": "After-hours email gate (Mon-Fri 9-5 PT only; no weekends)",
+      "source": "feedback_marketplace_local_only.md (after-hours quiet rule)",
+      "severity": "medium",
+      "check": "email-hourly.sh checks HOUR/DOW before sending; launchd plist scoped to Weekday 1-5 Hour 9-17",
+      "category": "compliance"
+    },
+    {
+      "id": 20,
+      "rule": "Every shipped artifact gets a Goal·How·Why explainer video via what-landed-video",
+      "source": "feedback_video_per_new_artifact.md + feedback_what_landed_video_every_tick.md",
+      "severity": "medium",
+      "check": "after each meaningful commit, ~/Videos/what-landed/<ISO>.mp4 generated",
+      "category": "publishing"
+    }
+  ]
+}
diff --git a/data/projects.json b/data/projects.json
index 18a3d194..51624099 100644
--- a/data/projects.json
+++ b/data/projects.json
@@ -1,7 +1,19 @@
 {
   "updated_at": "2026-05-14T16:17:06.279Z",
-  "count": 12,
+  "count": 13,
   "projects": [
+    {
+      "slug": "wallco-ai",
+      "name": "wallco.ai",
+      "path": "/Users/stevestudio2/Projects/wallco-ai",
+      "description": "AI-generated seamless wallpaper patterns — Replicate SDXL → settlement-gated → Spoonflower upload → Shopify draft. Includes the wallco Marketplace (Patternbank × Material Bank × Spoonflower for designer-powered wallcoverings) with commission ledger, badges, follower system. ~4,250 published designs as of 2026-05-14.",
+      "status": "live",
+      "port": 9905,
+      "domain": "wallco.ai",
+      "category": "ai-design",
+      "pm2_name": "wallco-ai",
+      "created_at": "2026-05-14T16:15:00.000Z"
+    },
     {
       "slug": "abrams-panel",
       "name": "Abrams Panel",
diff --git a/public/chat.html b/public/chat.html
index d4a22607..74a2e5a6 100644
--- a/public/chat.html
+++ b/public/chat.html
@@ -44,6 +44,7 @@
 <footer>© 2026 Steven Abrams</footer>
 
 <script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
 <script>
 function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
 const win = document.getElementById('window');
diff --git a/public/ideas.html b/public/ideas.html
index 826788b9..ac8e12cb 100644
--- a/public/ideas.html
+++ b/public/ideas.html
@@ -64,6 +64,7 @@
 <footer>© 2026 Steven Abrams</footer>
 
 <script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
 <script>
 function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
 let allIdeas = [];
diff --git a/public/index.html b/public/index.html
index de350ac6..c57ac25a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -98,6 +98,13 @@
   <div class="feed" id="feed-list"><div class="row"><span class="ts">connecting…</span><span class="type">SYS</span><span class="body">waiting for first event</span></div></div>
 </section>
 
+<!-- leaderboard -->
+<section id="leaderboard">
+  <h2>Leaderboard</h2>
+  <p class="sub">Live XP race · panel visitors earn XP for actions, unlock badges, build streaks. Click your HUD bottom-right to see your stats.</p>
+  <div id="leaderboard-list" style="font-family:var(--mono);font-size:12px;"></div>
+</section>
+
 <!-- lead capture -->
 <section id="lead">
   <h2>Get on the Panel</h2>
@@ -118,6 +125,7 @@
 </footer>
 
 <script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
 <script>
 // ── projects grid ──
 async function loadProjects() {
@@ -179,7 +187,13 @@ async function loadInitialFeed(){
 
 function startStream(){
   const es = new EventSource('/api/stream');
-  es.addEventListener('build', e => { try { addEvent(JSON.parse(e.data)); } catch {} });
+  es.addEventListener('build', e => {
+    try {
+      const ev = JSON.parse(e.data);
+      addEvent(ev);
+      document.dispatchEvent(new CustomEvent('abrams:build-event', { detail: ev }));
+    } catch {}
+  });
   es.onerror = () => { setTimeout(startStream, 3000); es.close(); };
 }
 
@@ -205,8 +219,31 @@ document.getElementById('density').addEventListener('input', e => { document.doc
 const savedDensity = localStorage.getItem('abrams.density');
 if (savedDensity) { document.getElementById('density').value = savedDensity; document.documentElement.style.setProperty('--cols', savedDensity); }
 
-loadProjects(); loadIdeas(); loadInitialFeed(); startStream();
-setInterval(()=>{loadProjects();loadIdeas();}, 60_000);
+async function loadLeaderboard() {
+  const r = await fetch('/api/gamify/leaderboard').then(x=>x.json()).catch(()=>({leaderboard:[]}));
+  const board = r.leaderboard || [];
+  const me = (window.Gamify && Gamify.state()) || null;
+  document.getElementById('leaderboard-list').innerHTML = `
+    <div style="display:grid;grid-template-columns:50px 1fr 90px 90px 90px 200px;gap:12px;padding:10px 0;border-bottom:1px solid var(--hairline);color:var(--fg-3);font-size:10px;letter-spacing:.2em;text-transform:uppercase;">
+      <span>#</span><span>Player</span><span>XP</span><span>LVL</span><span>Badges</span><span>Last seen</span>
+    </div>
+    ${board.slice(0,15).map((p,i) => {
+      const isMe = me && p.player === me.player;
+      return `<div style="display:grid;grid-template-columns:50px 1fr 90px 90px 90px 200px;gap:12px;padding:10px 0;border-bottom:1px solid var(--hairline);color:${isMe?'var(--accent)':'var(--fg)'};">
+        <span style="color:var(--fg-3);">${(i+1).toString().padStart(2,'0')}</span>
+        <span>${escapeHtml(p.player)}${isMe?' (you)':''}</span>
+        <span>${p.total_xp}</span>
+        <span>${p.level}</span>
+        <span>${p.badge_count}</span>
+        <span style="color:var(--fg-3);font-size:11px;">${new Date(p.last_seen).toLocaleTimeString()}</span>
+      </div>`;
+    }).join('') || '<div style="color:var(--fg-3);padding:24px;">no players yet — be the first</div>'}
+    <div style="margin-top:14px;color:var(--fg-3);font-size:11px;">Total events: ${r.total_events||0} · refreshes every 30s</div>
+  `;
+}
+
+loadProjects(); loadIdeas(); loadInitialFeed(); startStream(); loadLeaderboard();
+setInterval(()=>{loadProjects();loadIdeas();loadLeaderboard();}, 30_000);
 </script>
 </body>
 </html>
diff --git a/public/js/corner-nav.js b/public/js/corner-nav.js
index 92f3643a..0c9cd2eb 100644
--- a/public/js/corner-nav.js
+++ b/public/js/corner-nav.js
@@ -6,6 +6,9 @@
     { label: 'Thesis', href: '/#thesis' },
     { label: 'Projects', href: '/#projects' },
     { label: 'Ideas + SWOT', href: '/ideas' },
+    { label: 'Mag-20 Audit', href: '/mag-20' },
+    { label: 'Press Kit', href: '/press' },
+    { label: 'Leaderboard', href: '/#leaderboard' },
     { label: 'Trademark', href: '/trademark' },
     { label: 'Wall', href: '/wall' },
     { label: 'Chat', href: '/chat' },
diff --git a/public/js/gamify.js b/public/js/gamify.js
new file mode 100644
index 00000000..74bc38c4
--- /dev/null
+++ b/public/js/gamify.js
@@ -0,0 +1,247 @@
+// gamify.js — XP / levels / badges / streaks / confetti for the Abrams Panel.
+// Drop-in: include this script and call window.Gamify.event('action', xp) anywhere.
+// Persists per-player in localStorage + posts events to /api/gamify/event for the leaderboard.
+
+(function () {
+  const STORE_KEY = 'abrams.gamify.v1';
+  const SOUND_KEY = 'abrams.gamify.sound';
+  const PLAYER_KEY = 'abrams.gamify.player';
+
+  // ── load or init player ────────────────────────────────────────────
+  let player = localStorage.getItem(PLAYER_KEY);
+  if (!player) {
+    player = 'agent-' + Math.random().toString(36).slice(2, 8);
+    localStorage.setItem(PLAYER_KEY, player);
+  }
+
+  let state = {
+    player,
+    xp: 0,
+    level: 1,
+    streak_days: 0,
+    last_seen_day: null,
+    badges: [],
+    events_seen: 0,
+  };
+  try { Object.assign(state, JSON.parse(localStorage.getItem(STORE_KEY) || '{}')); state.player = player; } catch {}
+
+  const soundOn = () => localStorage.getItem(SOUND_KEY) !== 'off';
+  const setSound = (on) => localStorage.setItem(SOUND_KEY, on ? 'on' : 'off');
+
+  // ── levels (Fibonacci-style growth) ────────────────────────────────
+  const LEVELS = [0, 50, 150, 350, 750, 1500, 3000, 6000, 12000, 24000, 50000];
+  const levelFromXp = (xp) => { for (let i = LEVELS.length - 1; i >= 0; i--) if (xp >= LEVELS[i]) return i + 1; return 1; };
+  const xpNeededForNext = (lvl) => LEVELS[lvl] || (LEVELS[LEVELS.length - 1] * 2);
+
+  // ── badge catalog ──────────────────────────────────────────────────
+  const BADGES = {
+    'first-step':       { name: 'First Step',       icon: '◐',  desc: 'Visited the panel',                 trigger: s => s.events_seen >= 1 },
+    'curious-mind':     { name: 'Curious Mind',     icon: '✦',  desc: 'Viewed 5+ projects',                 trigger: s => (s._actions?.project_view || 0) >= 5 },
+    'thesis-reader':    { name: 'Thesis Reader',    icon: '※',  desc: 'Scrolled through the brand thesis',  trigger: s => (s._actions?.thesis_view || 0) >= 1 },
+    'mag-20-auditor':   { name: 'Mag-20 Auditor',   icon: '⚐',  desc: 'Opened the Mag-20 audit page',       trigger: s => (s._actions?.mag20_view || 0) >= 1 },
+    'swot-scholar':     { name: 'SWOT Scholar',     icon: '⚖',  desc: 'Opened the SWOT ledger',             trigger: s => (s._actions?.swot_view || 0) >= 1 },
+    'first-blood':      { name: 'First Blood',      icon: '★',  desc: 'Submitted the first lead',           trigger: s => (s._actions?.lead_submit || 0) >= 1 },
+    'wall-poster':      { name: 'Wall Poster',      icon: '✎',  desc: 'Posted to the Wall',                 trigger: s => (s._actions?.wall_post || 0) >= 1 },
+    'chatter':          { name: 'Chatter',          icon: '◗',  desc: 'Talked to Abrams 3+ times',          trigger: s => (s._actions?.chat_msg || 0) >= 3 },
+    'tm-investigator':  { name: 'TM Investigator',  icon: '®',  desc: 'Studied the Trademark hub',          trigger: s => (s._actions?.trademark_view || 0) >= 1 },
+    'tick-witness':     { name: 'Tick Witness',     icon: '⟳',  desc: 'Watched 3+ live build events',       trigger: s => (s._actions?.tick_witnessed || 0) >= 3 },
+    'streak-3':         { name: '3-Day Streak',     icon: '✶✶✶', desc: 'Visited 3 days in a row',           trigger: s => s.streak_days >= 3 },
+    'streak-7':         { name: 'Week Streak',      icon: '✸',  desc: 'Visited 7 days in a row',            trigger: s => s.streak_days >= 7 },
+    'high-roller':      { name: 'High Roller',      icon: '✤',  desc: 'Reached 1,500 XP',                   trigger: s => s.xp >= 1500 },
+    'panel-master':     { name: 'Panel Master',     icon: '✜',  desc: 'Reached 6,000 XP',                   trigger: s => s.xp >= 6000 },
+  };
+
+  // ── tone/beep (uses WebAudio so no asset loading) ──────────────────
+  let audioCtx = null;
+  function beep(freq = 660, dur = 0.08, type = 'sine', vol = 0.06) {
+    if (!soundOn()) return;
+    try {
+      if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+      const o = audioCtx.createOscillator(), g = audioCtx.createGain();
+      o.type = type; o.frequency.value = freq;
+      g.gain.value = vol;
+      o.connect(g); g.connect(audioCtx.destination);
+      o.start();
+      g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + dur);
+      o.stop(audioCtx.currentTime + dur);
+    } catch {}
+  }
+  function chord() { beep(523, 0.1); setTimeout(() => beep(659, 0.1), 90); setTimeout(() => beep(784, 0.18), 180); }
+
+  // ── confetti (canvas-free, span-based, lightweight) ────────────────
+  function confetti(n = 80) {
+    const colors = ['#c9a96e', '#fff', '#4ade80', '#f59e0b', '#94a3b8'];
+    for (let i = 0; i < n; i++) {
+      const el = document.createElement('span');
+      el.style.cssText = `position:fixed;top:-12px;left:${Math.random()*100}vw;width:8px;height:14px;background:${colors[i%colors.length]};opacity:.9;z-index:9999;border-radius:2px;pointer-events:none;transform:rotate(${Math.random()*360}deg);transition:transform 2.5s linear, top 2.5s linear, opacity 2.5s ease-out;`;
+      document.body.appendChild(el);
+      requestAnimationFrame(() => {
+        el.style.top = (60 + Math.random()*40) + 'vh';
+        el.style.transform = `rotate(${360 + Math.random()*720}deg) translateX(${(Math.random()-0.5)*200}px)`;
+        el.style.opacity = '0';
+      });
+      setTimeout(() => el.remove(), 2700);
+    }
+  }
+
+  // ── HUD widget ────────────────────────────────────────────────────
+  let hud;
+  function buildHud() {
+    if (hud) return;
+    hud = document.createElement('div');
+    hud.id = 'abrams-gamify-hud';
+    hud.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:90;background:rgba(20,20,20,.92);backdrop-filter:blur(6px);border:1px solid #2a2620;padding:12px 16px;font-family:JetBrains Mono,monospace;font-size:11px;color:#f1ece4;letter-spacing:.1em;border-radius:14px;min-width:220px;box-shadow:0 8px 32px rgba(0,0,0,.5);cursor:pointer;user-select:none;';
+    hud.innerHTML = `
+      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
+        <span style="color:#c9a96e;font-weight:600;letter-spacing:.2em;text-transform:uppercase;">LVL <span id="ag-level">1</span></span>
+        <span id="ag-sound" style="cursor:pointer;font-size:14px;opacity:.6;" title="toggle sound">♪</span>
+      </div>
+      <div style="display:flex;justify-content:space-between;color:#b8b1a4;margin-bottom:6px;">
+        <span><span id="ag-xp">0</span> XP</span>
+        <span><span id="ag-streak">0</span>🔥</span>
+      </div>
+      <div style="height:4px;background:#1d1d1d;border-radius:2px;overflow:hidden;margin-bottom:8px;">
+        <div id="ag-bar" style="height:100%;background:linear-gradient(90deg,#c9a96e,#fff);width:0%;transition:width .5s;"></div>
+      </div>
+      <div id="ag-badges" style="display:flex;gap:4px;flex-wrap:wrap;font-size:14px;line-height:1;"></div>
+      <div id="ag-popup" style="position:absolute;bottom:100%;right:0;margin-bottom:8px;background:#c9a96e;color:#111;padding:8px 14px;border-radius:8px;font-weight:600;letter-spacing:.15em;opacity:0;transform:translateY(8px);transition:all .3s;pointer-events:none;white-space:nowrap;text-transform:uppercase;font-size:10px;"></div>
+    `;
+    document.body.appendChild(hud);
+
+    document.getElementById('ag-sound').addEventListener('click', e => {
+      e.stopPropagation();
+      const on = !soundOn(); setSound(on);
+      document.getElementById('ag-sound').style.opacity = on ? '1' : '.3';
+    });
+    document.getElementById('ag-sound').style.opacity = soundOn() ? '1' : '.3';
+
+    hud.addEventListener('click', () => {
+      // expand to show full badges + leaderboard preview
+      window.location.href = '/#leaderboard';
+    });
+  }
+
+  function renderHud() {
+    if (!hud) return;
+    document.getElementById('ag-level').textContent = state.level;
+    document.getElementById('ag-xp').textContent = state.xp;
+    document.getElementById('ag-streak').textContent = state.streak_days;
+    const cur = state.xp - (LEVELS[state.level - 1] || 0);
+    const need = xpNeededForNext(state.level) - (LEVELS[state.level - 1] || 0);
+    document.getElementById('ag-bar').style.width = Math.min(100, (cur / Math.max(1, need)) * 100) + '%';
+    document.getElementById('ag-badges').innerHTML = state.badges.slice(-8).map(b => {
+      const info = BADGES[b] || { icon: '◌', name: b };
+      return `<span title="${info.name}: ${info.desc||''}" style="cursor:help;">${info.icon}</span>`;
+    }).join('');
+  }
+
+  function popup(text) {
+    const el = document.getElementById('ag-popup'); if (!el) return;
+    el.textContent = text;
+    el.style.opacity = '1'; el.style.transform = 'translateY(0)';
+    setTimeout(() => { el.style.opacity = '0'; el.style.transform = 'translateY(8px)'; }, 1800);
+  }
+
+  // ── streak tracker ────────────────────────────────────────────────
+  function checkStreak() {
+    const today = new Date().toISOString().slice(0, 10);
+    if (state.last_seen_day === today) return;
+    if (state.last_seen_day) {
+      const d1 = new Date(state.last_seen_day).getTime(), d2 = new Date(today).getTime();
+      const diff = Math.round((d2 - d1) / 86400000);
+      if (diff === 1) state.streak_days += 1;
+      else if (diff > 1) state.streak_days = 1;
+    } else state.streak_days = 1;
+    state.last_seen_day = today;
+  }
+
+  // ── core event API ────────────────────────────────────────────────
+  function event(action, xp = 10) {
+    state._actions = state._actions || {};
+    state._actions[action] = (state._actions[action] || 0) + 1;
+    state.xp += xp;
+    state.events_seen += 1;
+    const oldLevel = state.level;
+    state.level = levelFromXp(state.xp);
+    const newBadges = [];
+    for (const [id, b] of Object.entries(BADGES)) {
+      if (!state.badges.includes(id) && b.trigger(state)) {
+        state.badges.push(id);
+        newBadges.push(b);
+      }
+    }
+    persist();
+    renderHud();
+    // animate
+    if (xp > 0) { beep(660, 0.05); popup('+' + xp + ' XP'); }
+    if (state.level > oldLevel) { chord(); confetti(); setTimeout(() => popup('LEVEL UP! LVL ' + state.level), 200); }
+    for (const b of newBadges) { setTimeout(() => { beep(880, 0.12); popup('🏅 ' + b.name); }, 1200); }
+    // post to server (fire-and-forget)
+    try {
+      fetch('/api/gamify/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player: state.player, action, xp, level: state.level, badge: newBadges[0]?.name }) }).catch(()=>{});
+    } catch {}
+  }
+
+  function persist() { try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch {} }
+
+  // ── auto-event detectors based on current page ────────────────────
+  function autoDetect() {
+    const path = location.pathname;
+    if (path === '/' || path === '/index.html') event('panel_view', 5);
+    if (path.startsWith('/trademark')) event('trademark_view', 15);
+    if (path.startsWith('/ideas')) event('swot_view', 15);
+    if (path.startsWith('/wall')) event('wall_view', 10);
+    if (path.startsWith('/chat')) event('chat_view', 10);
+    if (path.startsWith('/mag-20')) event('mag20_view', 25);
+    if (path.startsWith('/press')) event('press_view', 15);
+
+    // thesis section view (IntersectionObserver)
+    setTimeout(() => {
+      const thesis = document.getElementById('thesis');
+      if (thesis && 'IntersectionObserver' in window) {
+        const io = new IntersectionObserver((entries) => {
+          entries.forEach(e => { if (e.isIntersecting) { event('thesis_view', 10); io.disconnect(); } });
+        }, { threshold: 0.3 });
+        io.observe(thesis);
+      }
+    }, 800);
+
+    // detect SSE build events being received → tick_witnessed (debounced)
+    let lastTick = 0;
+    document.addEventListener('abrams:build-event', () => {
+      const now = Date.now();
+      if (now - lastTick > 8000) { event('tick_witnessed', 2); lastTick = now; }
+    });
+
+    // detect lead-form submission
+    const leadForm = document.querySelector('form.lead-form');
+    if (leadForm) leadForm.addEventListener('submit', () => setTimeout(() => event('lead_submit', 100), 400));
+
+    // detect wall-post button click
+    const wallBtn = document.querySelector('.composer button');
+    if (wallBtn && /POST/i.test(wallBtn.textContent || '')) wallBtn.addEventListener('click', () => event('wall_post', 30));
+
+    // detect chat send
+    const chatBtn = document.querySelector('button[onclick*="send()"]');
+    if (chatBtn) chatBtn.addEventListener('click', () => event('chat_msg', 15));
+
+    // detect project tile click on home
+    document.querySelectorAll('.tile').forEach(t => t.addEventListener('click', () => event('project_view', 8), { once: true }));
+  }
+
+  // ── boot ──────────────────────────────────────────────────────────
+  function boot() {
+    buildHud();
+    checkStreak();
+    state.level = levelFromXp(state.xp);
+    persist();
+    renderHud();
+    autoDetect();
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+
+  // public surface
+  window.Gamify = { event, state: () => ({ ...state }), confetti, beep };
+})();
diff --git a/public/mag-20.html b/public/mag-20.html
new file mode 100644
index 00000000..9bdbcb69
--- /dev/null
+++ b/public/mag-20.html
@@ -0,0 +1,113 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>Abrams — Mag-20</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="/static/css/panel.css" />
+<style>
+  .container { max-width: 1400px; margin: 0 auto; padding: 100px 24px 60px; }
+  table.mag { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 11px; }
+  table.mag th, table.mag td { padding: 6px 8px; border: 1px solid var(--hairline); text-align: center; vertical-align: middle; }
+  table.mag th.rule { text-align: left; font-family: var(--sans); font-size: 12px; padding: 12px 16px; min-width: 280px; max-width: 320px; line-height: 1.4; white-space: normal; }
+  table.mag th.proj { writing-mode: vertical-rl; transform: rotate(180deg); padding: 12px 4px; font-size: 10px; letter-spacing: .1em; min-width: 28px; max-width: 28px; }
+  table.mag td.pass { background: rgba(74,222,128,.18); color: #4ade80; font-weight: 600; }
+  table.mag td.fail { background: rgba(239,68,68,.18); color: #ef4444; font-weight: 600; }
+  table.mag td.na { background: var(--bg-3); color: var(--fg-3); }
+  table.mag td.cat { color: var(--accent); font-size: 10px; letter-spacing: .15em; }
+  table.mag td.sev-critical { color: var(--err); }
+  table.mag td.sev-high { color: var(--warn); }
+  table.mag td.sev-medium { color: var(--fg-2); }
+  table.mag td.sev-low { color: var(--fg-3); }
+  table.mag tr.score-row td { background: #000; color: var(--accent); font-weight: 600; padding: 10px 8px; }
+  .legend { display: flex; gap: 18px; margin: 20px 0; font-family: var(--mono); font-size: 11px; letter-spacing: .15em; }
+  .legend span.pass { color: #4ade80; } .legend span.fail { color: #ef4444; } .legend span.na { color: var(--fg-3); }
+  .summary-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; margin: 24px 0; }
+  .summary-card { background: var(--bg-2); border: 1px solid var(--hairline); padding: 18px; }
+  .summary-card .lbl { font-family: var(--mono); font-size: 10px; color: var(--fg-3); letter-spacing: .25em; text-transform: uppercase; margin-bottom: 6px; }
+  .summary-card .val { font-family: var(--serif); font-size: 32px; color: var(--accent); }
+</style>
+</head>
+<body>
+
+<div class="chrome">
+  <a class="logo" href="/">ABRAMS<span class="dot">.</span></a>
+  <div class="nav-burger" role="button" aria-label="open menu">
+    <span class="bars"><span></span><span></span><span></span></span>
+    <span class="label">MENU</span>
+  </div>
+</div>
+
+<div class="container">
+  <h1 style="font-family:var(--serif);font-size:48px;font-weight:400;margin:0 0 6px;">Mag-20</h1>
+  <p style="color:var(--fg-2);max-width:760px;margin:0 0 12px;">The 20 standing-rule patterns every Abrams / DW-family / agentabrams.com property must comply with. Live audit matrix; refreshed every 15 min by the YOLO tick.</p>
+  <div class="legend">
+    <span class="pass">■ PASS</span>
+    <span class="fail">■ FAIL</span>
+    <span class="na">■ N/A</span>
+  </div>
+
+  <div class="summary-cards" id="summary"></div>
+
+  <div style="overflow-x:auto;border:1px solid var(--hairline);">
+    <table class="mag" id="matrix"></table>
+  </div>
+
+  <p style="color:var(--fg-3);font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;margin-top:16px;" id="meta"></p>
+</div>
+
+<footer>© 2026 Steven Abrams</footer>
+
+<script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
+<script>
+function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
+fetch('/api/mag-20').then(r=>r.json()).then(d => {
+  const patterns = d.patterns || [];
+  const results = d.results || { projects: [], summary: {} };
+  const projects = results.projects || [];
+
+  // summary cards
+  const totalPass = projects.reduce((a,p)=>a+(p.score||0), 0);
+  const totalFail = projects.reduce((a,p)=>a+(p.fail||0), 0);
+  const totalNa = projects.reduce((a,p)=>a+(p.na||0), 0);
+  const cells = totalPass + totalFail + totalNa;
+  const compliance = cells > 0 ? Math.round(100 * totalPass / (totalPass + totalFail)) : 0;
+  document.getElementById('summary').innerHTML = `
+    <div class="summary-card"><div class="lbl">Compliance</div><div class="val">${compliance}%</div></div>
+    <div class="summary-card"><div class="lbl">Pass cells</div><div class="val" style="color:#4ade80;">${totalPass}</div></div>
+    <div class="summary-card"><div class="lbl">Fail cells</div><div class="val" style="color:#ef4444;">${totalFail}</div></div>
+    <div class="summary-card"><div class="lbl">N/A cells</div><div class="val" style="color:var(--fg-3);">${totalNa}</div></div>
+    <div class="summary-card"><div class="lbl">Projects</div><div class="val">${projects.length}</div></div>
+    <div class="summary-card"><div class="lbl">Rules</div><div class="val">${patterns.length}</div></div>
+  `;
+
+  // matrix header
+  let html = '<thead><tr><th class="rule">Rule</th><th>Cat</th><th>Sev</th>';
+  projects.forEach(p => { html += `<th class="proj" title="${esc(p.name)}">${esc(p.name)}</th>`; });
+  html += '</tr></thead><tbody>';
+  patterns.forEach(pat => {
+    html += `<tr><th class="rule" title="${esc(pat.source||'')}">${pat.id}. ${esc(pat.rule)}</th>`;
+    html += `<td class="cat">${esc((pat.category||'').toUpperCase())}</td>`;
+    html += `<td class="sev-${esc(pat.severity)}">${esc((pat.severity||'').toUpperCase())}</td>`;
+    projects.forEach(p => {
+      const cell = p.checks?.[pat.id] || { status: 'n/a' };
+      const cls = cell.status === 'pass' ? 'pass' : (cell.status === 'fail' ? 'fail' : 'na');
+      const mark = cell.status === 'pass' ? '✓' : (cell.status === 'fail' ? '✗' : '–');
+      html += `<td class="${cls}" title="${esc(cell.evidence||'')}">${mark}</td>`;
+    });
+    html += '</tr>';
+  });
+  // score row
+  html += '<tr class="score-row"><td colspan="3">SCORE (pass / 20)</td>';
+  projects.forEach(p => { html += `<td>${p.score||0}</td>`; });
+  html += '</tr></tbody>';
+  document.getElementById('matrix').innerHTML = html;
+
+  document.getElementById('meta').textContent = `Last audit: ${new Date(results.generated_at).toLocaleString()} · ${projects.length} projects × ${patterns.length} rules = ${cells} checks`;
+});
+</script>
+</body>
+</html>
diff --git a/public/trademark.html b/public/trademark.html
index d16f4dc9..7fe03f2b 100644
--- a/public/trademark.html
+++ b/public/trademark.html
@@ -77,6 +77,7 @@
 <footer>© 2026 Steven Abrams · <a href="/">panel</a> · <a href="/ideas">SWOT</a> · <a href="https://agentabrams.com">agentabrams.com</a></footer>
 
 <script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
 <script>
 function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
 fetch('/api/trademark').then(r=>r.json()).then(tm => {
diff --git a/public/wall.html b/public/wall.html
index a37b65c3..e7e1eb41 100644
--- a/public/wall.html
+++ b/public/wall.html
@@ -45,6 +45,7 @@
 <footer>© 2026 Steven Abrams</footer>
 
 <script src="/static/js/corner-nav.js"></script>
+<script src="/static/js/gamify.js"></script>
 <script>
 function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
 async function load() {
diff --git a/scripts/mag-20-audit.js b/scripts/mag-20-audit.js
new file mode 100755
index 00000000..16764ba9
--- /dev/null
+++ b/scripts/mag-20-audit.js
@@ -0,0 +1,168 @@
+#!/usr/bin/env node
+// mag-20-audit.js — runs each of the 20 standing-rule patterns against every project.
+// Emits data/mag-20-results.json with pass/fail/n-a per (project × pattern).
+// Read-only.
+
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const ROOT = path.resolve(__dirname, '..');
+const HOME = process.env.HOME;
+const PROJECTS_DIR = path.join(HOME, 'Projects');
+const PATTERNS = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'mag-20.json'), 'utf8')).patterns;
+const PROJECTS = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'projects.json'), 'utf8')).projects;
+const OUT = path.join(ROOT, 'data', 'mag-20-results.json');
+const LOG = path.join(ROOT, 'data', 'build-log.jsonl');
+
+const log = (type, payload) => fs.appendFileSync(LOG, JSON.stringify({ ts: new Date().toISOString(), type, ...payload }) + '\n');
+
+const tryExec = (cmd) => {
+  try { return execSync(cmd, { encoding: 'utf8', stdio: ['pipe','pipe','ignore'] }).trim(); } catch { return null; }
+};
+const tryRead = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
+const htmlFiles = (dir) => {
+  try { return fs.readdirSync(path.join(dir, 'public')).filter(f => f.endsWith('.html')).map(f => path.join(dir, 'public', f)); }
+  catch { return []; }
+};
+
+function audit(project, pattern) {
+  const dir = project.path;
+  if (!dir || !fs.existsSync(dir)) return { status: 'n/a', evidence: 'no local path' };
+  const htmls = htmlFiles(dir);
+  switch (pattern.id) {
+    case 1: { // logo UL + hamburger UR
+      if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
+      const failing = htmls.filter(f => { const c = tryRead(f) || ''; return !/\.chrome[\s\S]*\.logo|class="logo"/.test(c) || !/nav-burger|\.hamburger/.test(c); });
+      return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing chrome` : `${htmls.length} pages compliant` };
+    }
+    case 2: { // Gucci ≡ MENU
+      if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
+      const failing = htmls.filter(f => !/\bMENU\b/.test(tryRead(f) || ''));
+      return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing MENU label` : 'all pages have MENU label' };
+    }
+    case 3: { // hero CTA single white-pill ENTER bottom
+      const idx = tryRead(path.join(dir, 'public/index.html'));
+      if (!idx) return { status: 'n/a', evidence: 'no index.html' };
+      const hasEnter = /class="cta"[^>]*>\s*ENTER/.test(idx);
+      const hasBottom = /\.cta\s*\{[\s\S]{0,400}bottom\s*:/.test(tryRead(path.join(dir, 'public/css/panel.css')) || '');
+      return { status: hasEnter && hasBottom ? 'pass' : 'fail', evidence: `enter:${hasEnter} bottom-anchored:${hasBottom}` };
+    }
+    case 4: return { status: 'n/a', evidence: 'rotating hero rule is for DW microsites, not panels' };
+    case 5: { // sort + density on every grid
+      const gridPages = htmls.filter(f => /class="grid"|id="grid"|class="ideas-grid"/.test(tryRead(f) || ''));
+      if (!gridPages.length) return { status: 'n/a', evidence: 'no grid pages' };
+      const failing = gridPages.filter(f => { const c = tryRead(f) || ''; return !/id="sort"/.test(c) || !/id="density"/.test(c); });
+      return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${gridPages.length} grid pages missing sort/density` : `${gridPages.length} grid pages compliant` };
+    }
+    case 6: { // corner-nav.js
+      if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
+      const failing = htmls.filter(f => !/corner-nav\.js/.test(tryRead(f) || ''));
+      return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing corner-nav.js` : 'all pages have corner-nav' };
+    }
+    case 7: { // ns-modal CSS when nsContactOpen used
+      const usesModal = htmls.filter(f => /nsContactOpen\(\)/.test(tryRead(f) || ''));
+      if (!usesModal.length) return { status: 'n/a', evidence: 'no contact modal trigger' };
+      const css = tryRead(path.join(dir, 'public/css/panel.css')) || '';
+      return { status: /\.ns-modal/.test(css) ? 'pass' : 'fail', evidence: /\.ns-modal/.test(css) ? 'CSS present' : 'modal trigger without CSS — fanout fix needed' };
+    }
+    case 8: return { status: 'n/a', evidence: 'customer-chat rule (lower-LEFT woman avatar) is DW-customer-facing only' };
+    case 9: return { status: 'n/a', evidence: 'chip rule applies to DW catalog grids' };
+    case 10: { // viewport meta tag
+      if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
+      const failing = htmls.filter(f => !/<meta\s+name=["']viewport["']/i.test(tryRead(f) || ''));
+      return { status: failing.length === 0 ? 'pass' : 'fail', evidence: failing.length ? `${failing.length}/${htmls.length} missing viewport meta` : 'all pages have viewport' };
+    }
+    case 11: { // gitified
+      return { status: fs.existsSync(path.join(dir, '.git')) ? 'pass' : 'fail', evidence: fs.existsSync(path.join(dir, '.git')) ? '.git/ present' : 'no git — gitify rule violation' };
+    }
+    case 12: { // commits per success
+      const count = parseInt(tryExec(`cd "${dir}" && git rev-list --count HEAD 2>/dev/null`) || '0', 10);
+      const days = parseInt(tryExec(`cd "${dir}" && git log --format="%ci" 2>/dev/null | head -1 | head -c 10 && echo`) ? Math.max(1, Math.floor((Date.now() - new Date(tryExec(`cd "${dir}" && git log --reverse --format="%ci" 2>/dev/null | head -1`)).getTime()) / 86400000)) : 1, 10);
+      const ratio = days > 0 ? count / days : count;
+      return { status: count >= 3 ? 'pass' : (count >= 1 ? 'fail' : 'n/a'), evidence: `${count} commits over ${days} days (${ratio.toFixed(2)}/day)` };
+    }
+    case 13: { // .gitignore essentials
+      const gi = tryRead(path.join(dir, '.gitignore')) || '';
+      const required = ['node_modules', '.env', 'tmp', '*.log', '.DS_Store'];
+      const missing = required.filter(r => !gi.includes(r));
+      return { status: missing.length === 0 ? 'pass' : 'fail', evidence: missing.length ? `missing: ${missing.join(', ')}` : 'all 5 essentials present' };
+    }
+    case 14: { // no AI vendor names exposed
+      if (!htmls.length) return { status: 'n/a', evidence: 'no html files' };
+      const leaks = [];
+      for (const f of htmls) {
+        const c = tryRead(f) || '';
+        const m = c.match(/\b(replicate|sdxl|gemini|claude-3|gpt-4|ollama|moonshot|kimi|deepseek|qwen3)\b/i);
+        if (m) leaks.push(`${path.basename(f)}: ${m[0]}`);
+      }
+      return { status: leaks.length === 0 ? 'pass' : 'fail', evidence: leaks.length ? `LEAKS: ${leaks.join('; ')}` : 'no vendor leaks' };
+    }
+    case 15: { // CAN-SPAM in email
+      const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
+      if (!e) return { status: 'n/a', evidence: 'no mailer in this project' };
+      const hasAddr = /\b\d{4,5}\b.{0,80}(Blvd|St|Ave|Rd|Dr|Ln|Way|Ct)/i.test(e);
+      const hasUnsub = /unsub|STOP|disable/i.test(e);
+      return { status: (hasAddr && hasUnsub) ? 'pass' : 'fail', evidence: `addr:${hasAddr} unsub:${hasUnsub}` };
+    }
+    case 16: { // mojibake guard on email subject
+      const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
+      if (!e) return { status: 'n/a', evidence: 'no mailer in this project' };
+      const subjectLine = (e.match(/"subject"\s*:\s*"[^"]+"/g) || []).join('|');
+      return { status: !/—|–/.test(subjectLine) ? 'pass' : 'fail', evidence: !/—|–/.test(subjectLine) ? 'ASCII-safe subject' : 'em-dash or en-dash in subject' };
+    }
+    case 17: { // bind 127.0.0.1
+      const s = tryRead(path.join(dir, 'server.js'));
+      if (!s) return { status: 'n/a', evidence: 'no server.js' };
+      const bindsLocal = /127\.0\.0\.1|BIND.*127/.test(s);
+      const bindsAll = /'0\.0\.0\.0'\s*\)/.test(s);
+      return { status: bindsLocal && !bindsAll ? 'pass' : 'fail', evidence: bindsLocal ? '127.0.0.1 default' : (bindsAll ? '0.0.0.0 hardcoded' : 'no listen() found') };
+    }
+    case 18: { // rate-limit POST routes
+      const s = tryRead(path.join(dir, 'server.js'));
+      if (!s) return { status: 'n/a', evidence: 'no server.js' };
+      const posts = (s.match(/app\.post\(/g) || []).length;
+      const limiters = (s.match(/rateLimit|Limiter/g) || []).length;
+      if (posts === 0) return { status: 'n/a', evidence: 'no POST routes' };
+      return { status: limiters >= 1 ? 'pass' : 'fail', evidence: `${limiters} limiters / ${posts} POSTs` };
+    }
+    case 19: { // after-hours email gate
+      const e = tryRead(path.join(dir, 'scripts/email-hourly.sh'));
+      if (!e) return { status: 'n/a', evidence: 'no hourly mailer in this project' };
+      const hasHour = /HOUR/.test(e) && /DOW/.test(e);
+      const hasGate = /-gt 17|-lt 9|-gt 5/.test(e);
+      return { status: (hasHour && hasGate) ? 'pass' : 'fail', evidence: `HOUR-gate:${hasHour} hour-bounds:${hasGate}` };
+    }
+    case 20: { // what-landed-video for each artifact
+      const videos = tryExec(`ls ~/Videos/what-landed/ 2>/dev/null | wc -l | tr -d ' '`);
+      return { status: parseInt(videos || '0', 10) > 0 ? 'pass' : 'fail', evidence: `${videos || 0} what-landed videos exist; per-project mapping TBD` };
+    }
+    default: return { status: 'n/a', evidence: 'no auditor for this rule' };
+  }
+}
+
+const results = { generated_at: new Date().toISOString(), projects: [], summary: {} };
+for (const proj of PROJECTS) {
+  const row = { slug: proj.slug, name: proj.name, status: proj.status, checks: {} };
+  for (const pat of PATTERNS) {
+    row.checks[pat.id] = audit(proj, pat);
+  }
+  row.score = Object.values(row.checks).filter(c => c.status === 'pass').length;
+  row.fail = Object.values(row.checks).filter(c => c.status === 'fail').length;
+  row.na = Object.values(row.checks).filter(c => c.status === 'n/a').length;
+  results.projects.push(row);
+}
+
+// summary by pattern
+for (const pat of PATTERNS) {
+  const cells = results.projects.map(p => p.checks[pat.id].status);
+  results.summary[pat.id] = {
+    pass: cells.filter(s => s === 'pass').length,
+    fail: cells.filter(s => s === 'fail').length,
+    na: cells.filter(s => s === 'n/a').length,
+  };
+}
+
+fs.writeFileSync(OUT, JSON.stringify(results, null, 2));
+log('mag-20-audit', { projects: results.projects.length, total_pass: results.projects.reduce((a,p)=>a+p.score,0), total_fail: results.projects.reduce((a,p)=>a+p.fail,0) });
+console.log(`mag-20: ${results.projects.length} projects × 20 rules → ${OUT}`);
diff --git a/scripts/tick.sh b/scripts/tick.sh
index 79a21e7c..ad4760bf 100755
--- a/scripts/tick.sh
+++ b/scripts/tick.sh
@@ -38,7 +38,14 @@ if [ -d .git ]; then
   fi
 fi
 
-# 4) drain any queued emails through George (if available)
+# 4) re-audit Mag-20
+if node scripts/mag-20-audit.js 2>>data/err.log; then
+  emit "mag-20-ok" "\"phase\":\"audit\""
+else
+  emit "mag-20-err" "\"phase\":\"audit\""
+fi
+
+# 5) drain any queued emails through George (if available)
 if [ -x scripts/drain-queue.sh ]; then
   bash scripts/drain-queue.sh 2>>data/err.log || true
 fi
diff --git a/server.js b/server.js
index 7365738d..636ed040 100644
--- a/server.js
+++ b/server.js
@@ -55,6 +55,41 @@ app.get('/chat', (_, res) => res.sendFile(path.join(ROOT, 'public', 'chat.html')
 app.get('/api/projects', (_, res) => res.json(readJson(PROJECTS_FILE, { projects: [], updated_at: null })));
 app.get('/api/ideas', (_, res) => res.json(readJson(IDEAS_FILE, { ideas: [], updated_at: null })));
 app.get('/api/trademark', (_, res) => res.json(readJson(TRADEMARK_FILE, {})));
+app.get('/api/mag-20', (_, res) => res.json({
+  patterns: readJson(path.join(DATA, 'mag-20.json'), { patterns: [] }).patterns || [],
+  results: readJson(path.join(DATA, 'mag-20-results.json'), { projects: [], summary: {} }),
+}));
+app.get('/api/brand-positioning', (_, res) => {
+  try { res.type('text/markdown').send(fs.readFileSync(path.join(DATA, 'brand-positioning.md'), 'utf8')); }
+  catch { res.status(404).json({ error: 'not yet generated' }); }
+});
+
+// gamify — XP/level/badge event log
+const GAMIFY_LOG = path.join(DATA, 'gamify-events.jsonl');
+if (!fs.existsSync(GAMIFY_LOG)) fs.writeFileSync(GAMIFY_LOG, '');
+const gamifyLimiter = rateLimit({ windowMs: 60_000, max: 60 });
+app.post('/api/gamify/event', gamifyLimiter, (req, res) => {
+  const { player, action, xp, level, badge } = req.body || {};
+  if (!action || typeof action !== 'string' || action.length > 60) return res.status(400).json({ ok: false, error: 'action required' });
+  appendJsonl(GAMIFY_LOG, { player: (player || 'anon').slice(0, 60), action: action.slice(0, 60), xp: Number.isFinite(xp) ? xp : 0, level: Number.isFinite(level) ? level : 1, badge: (badge || '').slice(0, 40) });
+  res.json({ ok: true });
+});
+app.get('/api/gamify/leaderboard', (_, res) => {
+  const events = readJsonl(GAMIFY_LOG, 2000);
+  const byPlayer = {};
+  for (const e of events) {
+    byPlayer[e.player] = byPlayer[e.player] || { player: e.player, total_xp: 0, level: 1, badges: new Set(), events: 0, last_seen: e.ts };
+    byPlayer[e.player].total_xp += e.xp || 0;
+    byPlayer[e.player].level = Math.max(byPlayer[e.player].level, e.level || 1);
+    if (e.badge) byPlayer[e.player].badges.add(e.badge);
+    byPlayer[e.player].events += 1;
+    byPlayer[e.player].last_seen = e.ts;
+  }
+  const board = Object.values(byPlayer).map(p => ({ ...p, badges: [...p.badges], badge_count: p.badges.size })).sort((a, b) => b.total_xp - a.total_xp).slice(0, 50);
+  res.json({ leaderboard: board, total_events: events.length });
+});
+
+app.get('/mag-20', (_, res) => res.sendFile(path.join(ROOT, 'public', 'mag-20.html')));
 app.get('/api/build-log', (req, res) => res.json({ events: readJsonl(BUILD_LOG, parseInt(req.query.n || '50', 10)) }));
 app.get('/api/wall', (req, res) => res.json({ posts: readJsonl(WALL_FILE, parseInt(req.query.n || '100', 10)) }));
 

← f681d782 tick: refresh inventory + SWOT at 2026-05-14T16:17:05Z  ·  back to Abrams  ·  v0.6: cross-project counters + press kit + lead-notify + com 53d79aea →