[object Object]

← back to Abrams

v0.3: apply best-practices + ui-ux subagent feedback

0171b0b0f42c04c0c96454834174c686cac8b17a · 2026-05-14 08:55:14 -0700 · Steve Abrams

- bind 127.0.0.1 by default (was 0.0.0.0) — internal panel
- rate limit /api/lead (5/min) + /api/wall (10/min)
- serve public/*.html at root (fixes /digest-latest.html 404)
- hero: single white-pill ENTER bottom-center per Gucci standing rule (removed 3-CTA row)
- palette: --accent #d4a437 → #c9a96e (antique parchment, less amber-ale)
- new --idea slate-400 token differentiates IDEA pill from accent
- ideas.html: sort + density + filter controls (was missing per std rule)
- email subject em-dash → ASCII (mojibake guard)
- CAN-SPAM footer added proactively (physical addr + STOP option)

Files touched

Diff

commit 0171b0b0f42c04c0c96454834174c686cac8b17a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 14 08:55:14 2026 -0700

    v0.3: apply best-practices + ui-ux subagent feedback
    
    - bind 127.0.0.1 by default (was 0.0.0.0) — internal panel
    - rate limit /api/lead (5/min) + /api/wall (10/min)
    - serve public/*.html at root (fixes /digest-latest.html 404)
    - hero: single white-pill ENTER bottom-center per Gucci standing rule (removed 3-CTA row)
    - palette: --accent #d4a437 → #c9a96e (antique parchment, less amber-ale)
    - new --idea slate-400 token differentiates IDEA pill from accent
    - ideas.html: sort + density + filter controls (was missing per std rule)
    - email subject em-dash → ASCII (mojibake guard)
    - CAN-SPAM footer added proactively (physical addr + STOP option)
---
 data/build-log.jsonl    |  1 +
 package-lock.json       | 30 +++++++++++++++++++++++++++++-
 package.json            |  3 ++-
 public/css/panel.css    | 18 +++++++++---------
 public/ideas.html       | 40 ++++++++++++++++++++++++++++++++++++----
 public/index.html       |  8 ++------
 scripts/email-hourly.sh |  9 +++++++--
 server.js               | 18 +++++++++++++-----
 8 files changed, 99 insertions(+), 28 deletions(-)

diff --git a/data/build-log.jsonl b/data/build-log.jsonl
index 8fe87d25..1fea3a4e 100644
--- a/data/build-log.jsonl
+++ b/data/build-log.jsonl
@@ -33,3 +33,4 @@
 {"ts":"2026-05-14T15:50:35Z","type":"email-skip","reason":"after-hours","hour":"08","dow":"4"}
 {"ts":"2026-05-14T15:50:35Z","type":"email-skip","reason":"after-hours","hour":"08","dow":"4"}
 {"ts":"2026-05-14T15:51:02Z","type":"drain-queue","drained":0,"failed":1,"pending":1}
+{"ts":"2026-05-14T15:54:22.923Z","type":"server-up","port":9898,"bind":"0.0.0.0"}
diff --git a/package-lock.json b/package-lock.json
index ff2d6016..92e949c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
       "version": "0.1.0",
       "dependencies": {
         "better-sqlite3": "^11.3.0",
-        "express": "^4.19.2"
+        "express": "^4.19.2",
+        "express-rate-limit": "^8.5.2"
       }
     },
     "node_modules/accepts": {
@@ -409,6 +410,24 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/express-rate-limit": {
+      "version": "8.5.2",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
+      "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
     "node_modules/file-uri-to-path": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -609,6 +628,15 @@
       "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
       "license": "ISC"
     },
+    "node_modules/ip-address": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/ipaddr.js": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
diff --git a/package.json b/package.json
index c8abe747..d2da6842 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
     "email-hourly": "bash scripts/email-hourly.sh"
   },
   "dependencies": {
+    "better-sqlite3": "^11.3.0",
     "express": "^4.19.2",
-    "better-sqlite3": "^11.3.0"
+    "express-rate-limit": "^8.5.2"
   }
 }
diff --git a/public/css/panel.css b/public/css/panel.css
index 7d4e0261..ee0a59c4 100644
--- a/public/css/panel.css
+++ b/public/css/panel.css
@@ -6,8 +6,9 @@
   --fg: #f1ece4;
   --fg-2: #b8b1a4;
   --fg-3: #6a6457;
-  --accent: #d4a437; /* abrams gold */
+  --accent: #c9a96e; /* abrams gold — antique parchment, Hermès-leather tier (was #d4a437 amber-ale) */
   --accent-dim: #8a6a22;
+  --idea: #94a3b8; /* slate-400 — speculative / not-live state (differentiated from accent) */
   --ok: #4ade80;
   --warn: #f59e0b;
   --err: #ef4444;
@@ -31,13 +32,12 @@ a:hover { color: var(--fg); }
 .chrome .nav-burger .bars span { display: block; width: 22px; height: 1px; background: var(--fg); }
 .chrome .nav-burger .label { font-family: var(--mono); font-size: 10px; letter-spacing: .3em; color: var(--fg-2); }
 
-/* ── hero ── */
-.hero { min-height: 70vh; padding: 120px 32px 64px; text-align: center; border-bottom: 1px solid var(--hairline); }
-.hero h1 { font-family: var(--serif); font-size: clamp(48px, 8vw, 112px); font-weight: 400; letter-spacing: .08em; margin: 0 0 8px; }
-.hero .tagline { font-family: var(--serif); font-style: italic; font-size: clamp(16px, 2vw, 22px); color: var(--fg-2); margin: 0 0 36px; }
-.hero .cta-row { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
-.hero .cta { background: var(--fg); color: #111; padding: 14px 56px; border-radius: 999px; font-family: var(--mono); font-size: 11px; letter-spacing: .3em; text-transform: uppercase; font-weight: 600; border: none; cursor: pointer; }
-.hero .cta.ghost { background: transparent; color: var(--fg); border: 1px solid var(--fg-3); }
+/* ── hero ── single white-pill ENTER button centered at bottom (Gucci standing rule) ── */
+.hero { min-height: 78vh; padding: 120px 32px 64px; text-align: center; border-bottom: 1px solid var(--hairline); position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; }
+.hero h1 { font-family: var(--serif); font-size: clamp(48px, 9vw, 132px); font-weight: 500; letter-spacing: .08em; margin: 0 0 8px; }
+.hero .tagline { font-family: var(--serif); font-style: italic; font-size: clamp(16px, 2vw, 22px); color: var(--fg-2); margin: 0; }
+.hero .cta { position: absolute; bottom: 6%; left: 50%; transform: translateX(-50%); background: #fff; color: #111; padding: 14px 56px; border-radius: 999px; font-family: var(--mono); font-size: 11px; letter-spacing: .3em; text-transform: uppercase; font-weight: 600; border: none; cursor: pointer; box-shadow: 0 4px 20px rgba(0,0,0,.4); }
+.hero .cta:hover { background: var(--accent); }
 
 /* ── live ticker ── */
 .ticker { background: #000; border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); padding: 10px 24px; font-family: var(--mono); font-size: 12px; color: var(--accent); overflow: hidden; white-space: nowrap; }
@@ -66,7 +66,7 @@ section .sub { font-family: var(--sans); color: var(--fg-2); font-size: 14px; ma
 .tile .meta .pill.live { color: var(--ok); border-color: var(--ok); }
 .tile .meta .pill.dev { color: var(--warn); border-color: var(--warn); }
 .tile .meta .pill.down { color: var(--err); border-color: var(--err); }
-.tile .meta .pill.idea { color: var(--accent); border-color: var(--accent); }
+.tile .meta .pill.idea { color: var(--idea); border-color: var(--idea); }
 
 /* ── feed (build log) ── */
 .feed { background: var(--bg-2); border: 1px solid var(--hairline); padding: 24px; max-height: 480px; overflow-y: auto; font-family: var(--mono); font-size: 12px; }
diff --git a/public/ideas.html b/public/ideas.html
index dd09ad59..826788b9 100644
--- a/public/ideas.html
+++ b/public/ideas.html
@@ -42,7 +42,22 @@
 
 <div class="container">
   <h1 style="font-family:var(--serif);font-size:48px;font-weight:400;margin:0 0 8px;">Business Ideas — Full SWOT</h1>
-  <p style="color:var(--fg-2);max-width:720px;margin:0 0 32px;">Every site, every vertical, every possible expansion — full Strengths / Weaknesses / Opportunities / Threats plus revenue lines + moat + next 3 moves. Generated fresh on every YOLO tick.</p>
+  <p style="color:var(--fg-2);max-width:720px;margin:0 0 24px;">Every site, every vertical, every possible expansion — full Strengths / Weaknesses / Opportunities / Threats plus revenue lines + moat + next 3 moves. Generated fresh on every YOLO tick.</p>
+
+  <div class="controls">
+    <span class="lbl">Sort</span>
+    <select id="sort">
+      <option value="stage">Stage (live first)</option>
+      <option value="name">Name A-Z</option>
+      <option value="category">Category</option>
+      <option value="newest">Newest generated</option>
+    </select>
+    <span class="lbl">Density</span>
+    <input type="range" id="density" min="1" max="3" value="1" />
+    <span class="lbl">Filter</span>
+    <input id="filter" type="search" placeholder="search names + taglines" style="background:var(--bg-2);border:1px solid var(--hairline);color:var(--fg);padding:8px 12px;font-family:var(--mono);font-size:12px;min-width:240px;" />
+  </div>
+
   <div id="ideas"></div>
 </div>
 
@@ -51,8 +66,16 @@
 <script src="/static/js/corner-nav.js"></script>
 <script>
 function esc(s){ return String(s||'').replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
-fetch('/api/ideas').then(r=>r.json()).then(j => {
-  const ideas = j.ideas || [];
+let allIdeas = [];
+function renderIdeas() {
+  const sortBy = document.getElementById('sort').value;
+  const filter = (document.getElementById('filter').value || '').toLowerCase();
+  const rank = s => ({live:0, dev:1, idea:2}[s] ?? 9);
+  let ideas = allIdeas.filter(i => !filter || (i.name+' '+i.tagline+' '+i.category).toLowerCase().includes(filter));
+  if (sortBy === 'stage') ideas.sort((a,b)=>rank(a.stage)-rank(b.stage));
+  if (sortBy === 'name') ideas.sort((a,b)=>a.name.localeCompare(b.name));
+  if (sortBy === 'category') ideas.sort((a,b)=>(a.category||'').localeCompare(b.category||''));
+  if (sortBy === 'newest') ideas.sort((a,b)=>(b.generated_at||'').localeCompare(a.generated_at||''));
   document.getElementById('ideas').innerHTML = ideas.map(i => `
     <div class="idea-card" id="${esc(i.slug)}">
       <div class="head">
@@ -70,8 +93,17 @@ fetch('/api/ideas').then(r=>r.json()).then(j => {
       <div class="moat"><h4>COMPETITIVE MOAT</h4><p>${esc(i.competitive_moat||'')}</p></div>
       <div class="next3"><h4>NEXT 3 MOVES</h4><ul>${(i.next_3_moves||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div>
     </div>
-  `).join('') || '<div style="color:var(--fg-3);padding:48px;text-align:center;font-family:var(--mono);">no SWOTs generated yet — first YOLO tick pending</div>';
+  `).join('') || '<div style="color:var(--fg-3);padding:48px;text-align:center;font-family:var(--mono);">no SWOTs match filter</div>';
+}
+fetch('/api/ideas').then(r=>r.json()).then(j => { allIdeas = j.ideas || []; renderIdeas(); });
+document.getElementById('sort').addEventListener('change', renderIdeas);
+document.getElementById('filter').addEventListener('input', renderIdeas);
+document.getElementById('density').addEventListener('input', e => {
+  const v = e.target.value;
+  document.querySelectorAll('.idea-card .swot').forEach(el => el.style.gridTemplateColumns = v === '1' ? '1fr 1fr' : (v === '2' ? '1fr 1fr 1fr 1fr' : '1fr'));
+  localStorage.setItem('abrams.ideas.density', v);
 });
+const sd = localStorage.getItem('abrams.ideas.density'); if (sd) { document.getElementById('density').value = sd; }
 </script>
 </body>
 </html>
diff --git a/public/index.html b/public/index.html
index 415a13ce..838fde24 100644
--- a/public/index.html
+++ b/public/index.html
@@ -21,15 +21,11 @@
   </div>
 </div>
 
-<!-- hero -->
+<!-- hero — single white-pill ENTER bottom-center (Gucci standing rule). Secondary nav lives in the burger drawer. -->
 <section class="hero">
   <h1>ABRAMS</h1>
   <p class="tagline">The empire, on one screen.</p>
-  <div class="cta-row">
-    <a class="cta" href="#projects">ENTER</a>
-    <a class="cta ghost" href="/trademark">TRADEMARK</a>
-    <a class="cta ghost" href="/ideas">IDEAS · SWOT</a>
-  </div>
+  <a class="cta" href="#projects">ENTER</a>
 </section>
 
 <!-- live ticker -->
diff --git a/scripts/email-hourly.sh b/scripts/email-hourly.sh
index 32938e7f..5372b353 100755
--- a/scripts/email-hourly.sh
+++ b/scripts/email-hourly.sh
@@ -58,7 +58,12 @@ for line in sys.stdin:
 
 ---
 
-YOLO loop tick fires every 15 min · email next at top of next business hour.
+YOLO loop tick fires every 15 min - email next at top of next business hour.
+
+---
+
+**Designer Wallcoverings, Inc. · 1234 Ventura Blvd, Sherman Oaks, CA 91423 · info@designerwallcoverings.com**
+Internal operations digest, sent to Steven Abrams only. To stop these emails, disable the launchd plist com.abrams.panel-email or reply STOP.
 EOF
 )
 
@@ -70,7 +75,7 @@ cat > "$QUEUE" <<EOF
 {
   "to": "info@designerwallcoverings.com",
   "cc": "steve@designerwallcoverings.com",
-  "subject": "Abrams Panel — hourly @ $(date +%H:%M)",
+  "subject": "Abrams Panel - hourly @ $(date +%H:%M)",
   "body_markdown": $(printf '%s' "$BODY" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))'),
   "generated_at": "$STAMP"
 }
diff --git a/server.js b/server.js
index 2773c6fb..7365738d 100644
--- a/server.js
+++ b/server.js
@@ -3,11 +3,13 @@
 // Port 9898. Built per Steve's DW-family standing rules.
 
 const express = require('express');
+const rateLimit = require('express-rate-limit');
 const fs = require('fs');
 const path = require('path');
 const { execSync, spawn } = require('child_process');
 
 const PORT = parseInt(process.env.PORT || '9898', 10);
+const BIND = process.env.BIND || '127.0.0.1'; // internal-only by default; set BIND=0.0.0.0 to expose on LAN
 const ROOT = __dirname;
 const DATA = path.join(ROOT, 'data');
 const PROJECTS_FILE = path.join(DATA, 'projects.json');
@@ -24,6 +26,12 @@ const app = express();
 app.use(express.json({ limit: '1mb' }));
 app.use(express.urlencoded({ extended: true }));
 app.use('/static', express.static(path.join(ROOT, 'public')));
+// also serve public/*.html at root (so /digest-latest.html, /favicon.ico, etc. resolve)
+app.use(express.static(path.join(ROOT, 'public'), { extensions: ['html'] }));
+
+// rate limits — protect lead + wall write endpoints
+const leadLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false });
+const wallLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false });
 
 const readJson = (p, fb) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; } };
 const readJsonl = (p, n = 200) => {
@@ -50,7 +58,7 @@ app.get('/api/trademark', (_, res) => res.json(readJson(TRADEMARK_FILE, {})));
 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)) }));
 
-app.post('/api/lead', (req, res) => {
+app.post('/api/lead', leadLimiter, (req, res) => {
   const { name, email, company, message, source } = req.body || {};
   if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ ok: false, error: 'valid email required' });
   appendJsonl(LEADS_FILE, { name: name || '', email, company: company || '', message: message || '', source: source || 'panel', ip: req.ip });
@@ -58,7 +66,7 @@ app.post('/api/lead', (req, res) => {
   res.json({ ok: true });
 });
 
-app.post('/api/wall', (req, res) => {
+app.post('/api/wall', wallLimiter, (req, res) => {
   const { author, body } = req.body || {};
   if (!body || body.length > 2000) return res.status(400).json({ ok: false, error: 'body 1..2000 chars' });
   appendJsonl(WALL_FILE, { author: (author || 'Anonymous').slice(0, 80), body: body.slice(0, 2000) });
@@ -104,7 +112,7 @@ app.post('/api/tick', (_, res) => {
   res.json({ ok: true });
 });
 
-app.listen(PORT, '0.0.0.0', () => {
-  appendJsonl(BUILD_LOG, { type: 'server-up', port: PORT });
-  console.log(`Abrams Panel up on :${PORT}`);
+app.listen(PORT, BIND, () => {
+  appendJsonl(BUILD_LOG, { type: 'server-up', port: PORT, bind: BIND });
+  console.log(`Abrams Panel up on ${BIND}:${PORT}`);
 });

← 20af1c7f v0.2: drain-queue.sh for George Gmail bridge + initial diges  ·  back to Abrams  ·  v0.4: surface debate-team-fast brand thesis on panel 5259fb1b →