← 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
M data/build-log.jsonlM package-lock.jsonM package.jsonM public/css/panel.cssM public/ideas.htmlM public/index.htmlM scripts/email-hourly.shM server.js
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →