SPB Git

spb/modelmap Public License

Internal cartography of local LLMs on Apple Silicon — registered, gated, negative-first. Public atlas at modelmap.io.

Python 66.3% JavaScript 24.5% CSS 8.1% Shell 0.7%

Site v2: editorial redesign, mobile-first responsive, comments, SVG logo

- Fraunces/Inter/JetBrains Mono typography, contour-line SVG logo (also
  favicon), gradient hero, hover cards, 4-column footer
- Hamburger dropdown menu <880px, full smartphone breakpoints (560px)
- /comments page: persistent JSON store OUTSIDE app dir (survives rsync
  redeploys), honeypot anti-spam, per-IP rate limit, XSS-escaped rendering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 4 h ago (Aug 12, 2026) parent 896d8e5

Showing 6 changed files with +552 and −128

added site/lib/comments.js +99 −0
@@ -0,0 +1,99 @@
1 +// ============================================================================
2 +// Project : modelmap
3 +// File : site/lib/comments.js
4 +// Purpose : Visitor comments — persistent JSON store, validation, limits
5 +// Author : Simon-Pierre Boucher
6 +// Contact : contact@spboucher.ai
7 +// Website : https://modelmap.io
8 +// Created : 2026-08-12
9 +// Modified : 2026-08-12
10 +// Platform : macOS / Apple Silicon (arm64) — Node.js (deployed on MacLustr)
11 +// License : All rights reserved (research code)
12 +// ============================================================================
13 +"use strict";
14 +
15 +const fs = require("fs");
16 +const os = require("os");
17 +const path = require("path");
18 +const crypto = require("crypto");
19 +
20 +// Stored OUTSIDE the app directory so rsync --delete redeploys never wipe it.
21 +const STORE = process.env.COMMENTS_FILE ||
22 + path.join(os.homedir(), ".modelmap-web", "comments.json");
23 +
24 +const MAX_NAME = 60;
25 +const MAX_MESSAGE = 2000;
26 +const MIN_MESSAGE = 3;
27 +const MAX_TOTAL = 5000; // hard cap on stored comments
28 +const RATE_LIMIT = 5; // posts per IP per window
29 +const RATE_WINDOW_MS = 60 * 60 * 1000;
30 +
31 +const recentByIp = new Map(); // ipHash -> [timestamps]
32 +
33 +function ensureStore() {
34 + fs.mkdirSync(path.dirname(STORE), { recursive: true });
35 + if (!fs.existsSync(STORE)) fs.writeFileSync(STORE, "[]\n");
36 +}
37 +
38 +function list() {
39 + try {
40 + ensureStore();
41 + const all = JSON.parse(fs.readFileSync(STORE, "utf8"));
42 + return Array.isArray(all) ? all.slice().reverse() : [];
43 + } catch {
44 + return [];
45 + }
46 +}
47 +
48 +function clean(s, max) {
49 + return String(s || "")
50 + .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "")
51 + .replace(/\s+/g, (m) => (m.includes("\n") ? "\n" : " "))
52 + .trim()
53 + .slice(0, max);
54 +}
55 +
56 +function ipHash(ip) {
57 + return crypto.createHash("sha256").update(String(ip || "")).digest("hex").slice(0, 16);
58 +}
59 +
60 +function rateLimited(ip) {
61 + const key = ipHash(ip);
62 + const now = Date.now();
63 + const times = (recentByIp.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS);
64 + if (times.length >= RATE_LIMIT) return true;
65 + times.push(now);
66 + recentByIp.set(key, times);
67 + return false;
68 +}
69 +
70 +/**
71 + * Add a comment. Returns { ok, error? }.
72 + * honeypot: hidden "website" field — bots fill it, humans don't.
73 + */
74 +function add({ name, message, honeypot, ip }) {
75 + if (honeypot) return { ok: true, dropped: true }; // pretend success, drop silently
76 + const n = clean(name, MAX_NAME) || "Anonymous";
77 + const m = clean(message, MAX_MESSAGE);
78 + if (m.length < MIN_MESSAGE) return { ok: false, error: "Message is too short." };
79 + if (rateLimited(ip)) return { ok: false, error: "Too many comments from this address — try again later." };
80 + ensureStore();
81 + let all;
82 + try {
83 + all = JSON.parse(fs.readFileSync(STORE, "utf8"));
84 + if (!Array.isArray(all)) all = [];
85 + } catch {
86 + all = [];
87 + }
88 + all.push({
89 + id: crypto.randomUUID(),
90 + name: n,
91 + message: m,
92 + created: new Date().toISOString(),
93 + });
94 + if (all.length > MAX_TOTAL) all = all.slice(all.length - MAX_TOTAL);
95 + fs.writeFileSync(STORE, JSON.stringify(all, null, 2) + "\n");
96 + return { ok: true };
97 +}
98 +
99 +module.exports = { list, add, STORE };
modified site/lib/render.js +51 −7
@@ -69,6 +69,7 @@ const NAV = [
69 69 ["/atlas", "Atlas"],
70 70 ["/results", "Results"],
71 71 ["/code", "Code"],
72 + ["/comments", "Comments"],
72 73 ["/about", "About"],
73 74 ];
74 75
@@ -95,6 +96,15 @@ function confidenceLadder() {
95 96 ).join("") + `</ol>`;
96 97 }
97 98
99 +/** Small inline SVG logo — contour lines, a nod to cartography. */
100 +const LOGO = `<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
101 +<circle cx="16" cy="16" r="13" fill="none" stroke="currentColor" stroke-width="1.6" opacity="0.9"/>
102 +<ellipse cx="16" cy="16" rx="8.5" ry="12" fill="none" stroke="currentColor" stroke-width="1.2" opacity="0.55"/>
103 +<ellipse cx="16" cy="16" rx="4" ry="10.5" fill="none" stroke="currentColor" stroke-width="1" opacity="0.35"/>
104 +<path d="M3.5 13.5 H28.5 M4.5 20.5 H27.5" stroke="currentColor" stroke-width="1" opacity="0.45" fill="none"/>
105 +<circle cx="20.5" cy="11" r="2.1" fill="currentColor"/>
106 +</svg>`;
107 +
98 108 function layout({ title, active, body, buildInfo = {} }) {
99 109 const nav = NAV.map(
100 110 ([href, label]) =>
@@ -107,23 +117,57 @@ function layout({ title, active, body, buildInfo = {} }) {
107 117 <head>
108 118 <meta charset="utf-8">
109 119 <meta name="viewport" content="width=device-width, initial-scale=1">
120 +<meta name="theme-color" content="#faf9f6">
110 121 <title>${esc(title)} · modelmap</title>
111 122 <meta name="description" content="modelmap — internal cartography of local large language models on consumer Apple Silicon. A reproducible, confidence-labeled research atlas by Simon-Pierre Boucher.">
123 +<link rel="preconnect" href="https://fonts.googleapis.com">
124 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
125 +<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
112 126 <link rel="stylesheet" href="/static/style.css">
113 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🗺️</text></svg>">
127 +<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
128 +<link rel="apple-touch-icon" href="/static/logo.svg">
114 129 </head>
115 130 <body>
116 <header class="site-header">
131 +<header class="site-header" id="top">
117 132 <div class="wrap header-row">
118 <a class="brand" href="/">modelmap<span class="brand-dim">.io</span></a>
119 <nav class="site-nav">${nav}</nav>
133 + <a class="brand" href="/">${LOGO}modelmap<span class="brand-dim">.io</span></a>
134 + <button class="nav-toggle" id="nav-toggle" aria-label="Menu" aria-expanded="false" aria-controls="site-nav">
135 + <span></span><span></span><span></span>
136 + </button>
137 + <nav class="site-nav" id="site-nav">${nav}</nav>
120 138 </div>
121 139 </header>
122 140 <main class="wrap">${body}</main>
123 141 <footer class="site-footer">
124 <div class="wrap">
125 <span>© 2026 Simon-Pierre Boucher — <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> · <a href="https://modelmap.io">modelmap.io</a> · All rights reserved (research code)</span>
126 <span class="footer-meta">snapshot ${esc(commit)}${synced ? " · synced " + esc(synced) : ""} · sister project: <a href="https://www.localvm.dev">localvm-research</a></span>
142 + <div class="wrap footer-grid">
143 + <div class="footer-col footer-brand">
144 + <span class="brand-foot">${LOGO}modelmap<span class="brand-dim">.io</span></span>
145 + <p>Internal cartography of local large language models — every map versioned,
146 + provenanced, confidence-labeled, and regenerable on a consumer Mac.</p>
147 + </div>
148 + <div class="footer-col">
149 + <h4>Explore</h4>
150 + <a href="/research">Research</a>
151 + <a href="/experiments">Experiments</a>
152 + <a href="/atlas">Atlas</a>
153 + <a href="/results">Raw results</a>
154 + </div>
155 + <div class="footer-col">
156 + <h4>Project</h4>
157 + <a href="/doc/CLAUDE.md">Research charter</a>
158 + <a href="/doc/research/LOG.md">Research log</a>
159 + <a href="/comments">Leave a comment</a>
160 + <a href="https://www.localvm.dev">Sister project: localvm</a>
161 + </div>
162 + <div class="footer-col">
163 + <h4>Author</h4>
164 + <span>Simon-Pierre Boucher</span>
165 + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a>
166 + <span class="footer-meta">snapshot ${esc(commit)}${synced ? " · synced " + esc(synced) : ""}</span>
167 + </div>
168 + </div>
169 + <div class="wrap footer-bottom">
170 + <span>© 2026 Simon-Pierre Boucher · All rights reserved (research code)</span>
127 171 </div>
128 172 </footer>
129 173 <script src="/static/app.js" defer></script>
modified site/public/app.js +27 −1
@@ -1,7 +1,7 @@
1 1 // ============================================================================
2 2 // Project : modelmap
3 3 // File : site/public/app.js
4 // Purpose : Client-side interactivity (chart hover tooltips)
4 +// Purpose : Client-side interactivity — mobile menu, chart tooltips
5 5 // Author : Simon-Pierre Boucher
6 6 // Contact : contact@spboucher.ai
7 7 // Website : https://modelmap.io
@@ -12,6 +12,32 @@
12 12 // ============================================================================
13 13 "use strict";
14 14
15 +// ---------------------------------------------------------- mobile nav
16 +(function () {
17 + const btn = document.getElementById("nav-toggle");
18 + const nav = document.getElementById("site-nav");
19 + if (!btn || !nav) return;
20 + btn.addEventListener("click", () => {
21 + const open = nav.classList.toggle("open");
22 + btn.setAttribute("aria-expanded", open ? "true" : "false");
23 + });
24 + // close when a link is chosen or when tapping outside
25 + nav.addEventListener("click", (e) => {
26 + if (e.target.tagName === "A") {
27 + nav.classList.remove("open");
28 + btn.setAttribute("aria-expanded", "false");
29 + }
30 + });
31 + document.addEventListener("click", (e) => {
32 + if (!nav.classList.contains("open")) return;
33 + if (!nav.contains(e.target) && !btn.contains(e.target)) {
34 + nav.classList.remove("open");
35 + btn.setAttribute("aria-expanded", "false");
36 + }
37 + });
38 +})();
39 +
40 +// ---------------------------------------------------------- chart tooltips
15 41 (function () {
16 42 const fig = document.querySelector(".chart-fig");
17 43 if (!fig) return;
added site/public/logo.svg +41 −0
@@ -0,0 +1,41 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!-- ==========================================================================
3 + Project : modelmap
4 + File : site/public/logo.svg
5 + Purpose : Platform logo & favicon — contour-line cartography mark
6 + Author : Simon-Pierre Boucher
7 + Contact : contact@spboucher.ai
8 + Website : https://modelmap.io
9 + Created : 2026-08-12
10 + Modified : 2026-08-12
11 + Platform : Web (deployed from macOS / Apple Silicon)
12 + License : All rights reserved (research code)
13 +=========================================================================== -->
14 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
15 + <defs>
16 + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
17 + <stop offset="0" stop-color="#faf9f6"/>
18 + <stop offset="1" stop-color="#f1edfa"/>
19 + </linearGradient>
20 + <linearGradient id="ink" x1="0" y1="0" x2="1" y2="1">
21 + <stop offset="0" stop-color="#6d4fc4"/>
22 + <stop offset="1" stop-color="#3b2775"/>
23 + </linearGradient>
24 + </defs>
25 +
26 + <!-- tile -->
27 + <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#bg)" stroke="#e6e3da" stroke-width="2"/>
28 +
29 + <!-- contour lines: the "terrain" of a model's internals -->
30 + <g fill="none" stroke="url(#ink)" stroke-linecap="round">
31 + <circle cx="64" cy="64" r="44" stroke-width="5" opacity="0.95"/>
32 + <ellipse cx="64" cy="64" rx="29" ry="41" stroke-width="4" opacity="0.55"/>
33 + <ellipse cx="64" cy="64" rx="14" ry="37" stroke-width="3" opacity="0.35"/>
34 + <path d="M22 55 H106" stroke-width="3.5" opacity="0.5"/>
35 + <path d="M25 78 H103" stroke-width="3.5" opacity="0.5"/>
36 + </g>
37 +
38 + <!-- the located feature: a verified landmark on the map -->
39 + <circle cx="80" cy="45" r="9" fill="url(#ink)"/>
40 + <circle cx="80" cy="45" r="14.5" fill="none" stroke="#6d4fc4" stroke-width="2.5" opacity="0.45"/>
41 +</svg>
modified site/public/style.css +282 −120
@@ -1,7 +1,7 @@
1 1 /* ============================================================================
2 2 * Project : modelmap
3 3 * File : site/public/style.css
4 * Purpose : Light-theme styling for the modelmap.io research atlas
4 + * Purpose : Editorial light theme v2 — responsive, mobile-first refinements
5 5 * Author : Simon-Pierre Boucher
6 6 * Contact : contact@spboucher.ai
7 7 * Website : https://modelmap.io
@@ -13,203 +13,304 @@
13 13
14 14 :root {
15 15 color-scheme: light;
16 --page: #f9f9f7;
17 --surface: #fcfcfb;
18 --ink: #0b0b0b;
19 --ink-2: #52514e;
20 --muted: #898781;
21 --grid: #e1e0d9;
22 --baseline: #c3c2b7;
23 --border: rgba(11, 11, 11, 0.10);
16 + --page: #faf9f6;
17 + --surface: #ffffff;
18 + --surface-2: #f4f2ec;
19 + --ink: #17151f;
20 + --ink-2: #4e4a5a;
21 + --muted: #8b8798;
22 + --grid: #e6e3da;
23 + --baseline: #c9c5ba;
24 24 --accent: #6d4fc4;
25 25 --accent-dark: #53389e;
26 + --accent-deep: #3b2775;
26 27 --accent-bg: #f1edfa;
27 --good: #0ca30c;
28 --good-text: #006300;
29 --warn: #eda100;
28 + --accent-glow: rgba(109, 79, 196, 0.12);
29 + --good: #0e9f4e;
30 + --good-text: #0a6b36;
31 + --good-bg: #e9f7ef;
32 + --warn-text: #9a6a00;
33 + --warn-bg: #fdf3e2;
34 + --shadow-sm: 0 1px 2px rgba(23, 21, 31, 0.05), 0 2px 8px rgba(23, 21, 31, 0.04);
35 + --shadow-md: 0 2px 4px rgba(23, 21, 31, 0.05), 0 10px 28px rgba(23, 21, 31, 0.08);
36 + --radius: 14px;
37 + --font-body: "Inter", system-ui, -apple-system, sans-serif;
38 + --font-display: "Fraunces", Georgia, serif;
39 + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
30 40 }
31 41
32 42 * { box-sizing: border-box; }
33 html { -webkit-text-size-adjust: 100%; }
43 +html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
34 44 body {
35 45 margin: 0;
36 font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
46 + font-family: var(--font-body);
37 47 background: var(--page);
38 48 color: var(--ink);
39 line-height: 1.6;
49 + line-height: 1.65;
40 50 font-size: 16px;
41 51 }
42 .wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
52 +.wrap { max-width: 1120px; margin: 0 auto; padding: 0 24px; }
43 53 a { color: var(--accent-dark); text-decoration: none; }
44 a:hover { text-decoration: underline; }
45 code, pre, .mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
54 +a:hover { text-decoration: underline; text-underline-offset: 3px; }
55 +code, pre, .mono-small { font-family: var(--font-mono); }
56 +::selection { background: var(--accent-bg); color: var(--accent-deep); }
46 57
47 /* header */
58 +h1, h2, h3, .page-title, .section-title { font-family: var(--font-display); font-weight: 550; }
59 +
60 +/* ---------------------------------------------------------------- header */
48 61 .site-header {
49 background: var(--surface);
62 + background: rgba(250, 249, 246, 0.85);
63 + backdrop-filter: blur(14px);
64 + -webkit-backdrop-filter: blur(14px);
50 65 border-bottom: 1px solid var(--grid);
51 position: sticky; top: 0; z-index: 10;
66 + position: sticky; top: 0; z-index: 50;
67 +}
68 +.header-row { display: flex; align-items: center; justify-content: space-between; height: 62px; position: relative; }
69 +.brand {
70 + font-family: var(--font-display);
71 + font-weight: 600; font-size: 21px; color: var(--ink); letter-spacing: -0.01em;
72 + display: inline-flex; align-items: center; gap: 9px;
52 73 }
53 .header-row { display: flex; align-items: center; justify-content: space-between; height: 56px; }
54 .brand { font-weight: 700; font-size: 18px; color: var(--ink); letter-spacing: -0.02em; }
55 .brand-dim { color: var(--muted); font-weight: 500; }
56 .site-nav { display: flex; gap: 4px; flex-wrap: wrap; }
74 +.brand:hover { text-decoration: none; }
75 +.brand-dim { color: var(--muted); font-weight: 450; }
76 +.logo { width: 26px; height: 26px; color: var(--accent); flex: 0 0 auto; }
77 +
78 +.site-nav { display: flex; gap: 2px; flex-wrap: wrap; }
57 79 .site-nav a {
58 padding: 6px 12px; border-radius: 6px; color: var(--ink-2); font-size: 14px; font-weight: 500;
80 + padding: 7px 13px; border-radius: 999px; color: var(--ink-2);
81 + font-size: 14px; font-weight: 500; transition: background 0.15s, color 0.15s;
82 +}
83 +.site-nav a:hover { background: var(--surface-2); text-decoration: none; color: var(--ink); }
84 +.site-nav a.active { color: var(--accent-deep); background: var(--accent-bg); font-weight: 600; }
85 +
86 +/* hamburger — hidden on desktop */
87 +.nav-toggle {
88 + display: none; flex-direction: column; justify-content: center; gap: 5px;
89 + width: 44px; height: 44px; padding: 10px;
90 + background: none; border: 1px solid var(--grid); border-radius: 10px; cursor: pointer;
91 +}
92 +.nav-toggle span {
93 + display: block; height: 2px; width: 100%; background: var(--ink);
94 + border-radius: 2px; transition: transform 0.25s, opacity 0.2s;
59 95 }
60 .site-nav a:hover { background: var(--page); text-decoration: none; color: var(--ink); }
61 .site-nav a.active { color: var(--accent-dark); background: var(--accent-bg); }
96 +.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
97 +.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
98 +.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
62 99
63 /* hero */
64 .hero { padding: 56px 0 8px; }
100 +/* ---------------------------------------------------------------- hero */
101 +.hero {
102 + padding: 72px 0 16px;
103 + position: relative;
104 +}
105 +.hero::before {
106 + content: ""; position: absolute; inset: -62px -50vw 0;
107 + background:
108 + radial-gradient(600px 320px at 78% 0%, var(--accent-glow), transparent 70%),
109 + radial-gradient(420px 260px at 12% 18%, rgba(14, 159, 78, 0.06), transparent 70%);
110 + pointer-events: none; z-index: -1;
111 +}
65 112 .kicker {
66 text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; font-weight: 600;
67 color: var(--accent-dark); margin: 0 0 12px;
113 + text-transform: uppercase; letter-spacing: 0.14em; font-size: 12px; font-weight: 650;
114 + color: var(--accent-dark); margin: 0 0 14px; font-family: var(--font-body);
68 115 }
69 .hero h1 { font-size: 40px; line-height: 1.15; letter-spacing: -0.02em; margin: 0 0 16px; }
70 .lede { font-size: 18px; color: var(--ink-2); max-width: 760px; margin: 0 0 12px; }
71 .lede-small { color: var(--ink-2); max-width: 760px; }
116 +.hero h1 {
117 + font-size: clamp(34px, 5.6vw, 54px);
118 + line-height: 1.08; letter-spacing: -0.015em; margin: 0 0 18px; font-weight: 560;
119 +}
120 +.hero h1 em, .lede em { font-style: italic; color: var(--accent-deep); }
121 +.lede { font-size: 18.5px; color: var(--ink-2); max-width: 780px; margin: 0 0 14px; }
122 +.lede-small { color: var(--ink-2); max-width: 780px; }
123 +.lede-eq { margin-top: 18px; }
72 124 .lede-eq code {
73 font-size: 13px; background: var(--surface); border: 1px solid var(--grid);
74 border-radius: 6px; padding: 6px 10px; color: var(--ink-2); display: inline-block;
125 + font-size: 12.5px; background: var(--surface); border: 1px solid var(--grid);
126 + border-radius: 10px; padding: 9px 14px; color: var(--ink-2); display: inline-block;
127 + box-shadow: var(--shadow-sm); line-height: 1.7;
75 128 }
76 129
77 /* stat tiles */
78 .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin: 32px 0; }
130 +/* ---------------------------------------------------------------- tiles */
131 +.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 40px 0; }
79 132 .tile {
80 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
81 padding: 20px 22px; display: flex; flex-direction: column; gap: 2px;
133 + background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius);
134 + padding: 22px 24px; display: flex; flex-direction: column; gap: 2px;
135 + box-shadow: var(--shadow-sm); transition: transform 0.18s, box-shadow 0.18s;
136 +}
137 +.tile:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
138 +.tile-value {
139 + font-family: var(--font-display); font-size: 40px; font-weight: 560;
140 + letter-spacing: -0.02em; color: var(--accent-deep); line-height: 1.1;
82 141 }
83 .tile-value { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; }
84 .tile-denom { font-size: 20px; color: var(--muted); font-weight: 500; }
85 .tile-label { color: var(--ink-2); font-size: 13.5px; }
142 +.tile-denom { font-size: 22px; color: var(--muted); font-weight: 450; }
143 +.tile-label { color: var(--ink-2); font-size: 13.5px; margin-top: 4px; }
86 144
87 /* layout blocks */
88 .split { display: grid; grid-template-columns: 3fr 2fr; gap: 20px; margin: 8px 0 20px; }
89 @media (max-width: 880px) { .split { grid-template-columns: 1fr; } .hero h1 { font-size: 30px; } }
145 +/* ---------------------------------------------------------------- layout */
146 +.split { display: grid; grid-template-columns: 3fr 2fr; gap: 22px; margin: 8px 0 22px; }
90 147 .card {
91 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
92 padding: 24px 26px; margin-bottom: 20px;
148 + background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius);
149 + padding: 28px 30px; margin-bottom: 22px; box-shadow: var(--shadow-sm);
93 150 }
94 .card h2 { margin-top: 0; font-size: 20px; letter-spacing: -0.01em; }
151 +.card h2 { margin-top: 0; font-size: 22px; letter-spacing: -0.01em; }
95 152 .more { font-weight: 600; font-size: 14px; }
153 +.more::after { content: ""; }
96 154
97 /* phases */
155 +/* ---------------------------------------------------------------- phases */
98 156 .phases { list-style: none; margin: 0; padding: 0; }
99 .phase { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--grid); }
157 +.phase { display: flex; align-items: flex-start; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--grid); }
100 158 .phase:last-child { border-bottom: 0; }
101 .phase strong { display: block; font-size: 14.5px; }
102 .phase-detail { font-size: 13px; color: var(--muted); }
159 +.phase strong { display: block; font-size: 14px; font-family: var(--font-body); font-weight: 600; }
160 +.phase-detail { font-size: 12.5px; color: var(--muted); }
103 161 .phase-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 7px; flex: 0 0 auto; background: var(--baseline); }
104 .phase.done .phase-dot { background: var(--good); }
105 .phase.in-progress .phase-dot { background: var(--accent); }
162 +.phase.done .phase-dot { background: var(--good); box-shadow: 0 0 0 3px var(--good-bg); }
163 +.phase.in-progress .phase-dot { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
106 164 .phase .badge { margin-left: auto; }
165 +
107 166 .badge {
108 font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 20px;
109 white-space: nowrap; align-self: center; border: 1px solid var(--grid); color: var(--ink-2);
167 + font-size: 11px; font-weight: 650; padding: 3px 10px; border-radius: 999px;
168 + white-space: nowrap; align-self: center; border: 1px solid var(--grid);
169 + color: var(--ink-2); letter-spacing: 0.02em;
110 170 }
111 .badge-done { color: var(--good-text); background: #eef7ee; border-color: #cfe8cf; }
112 .badge-in-progress, .badge-has-results { color: var(--accent-dark); background: var(--accent-bg); border-color: #dcd2f2; }
113 .badge-pending, .badge-scaffolded { color: var(--muted); background: var(--page); }
171 +.badge-done { color: var(--good-text); background: var(--good-bg); border-color: #cfe8d8; }
172 +.badge-in-progress, .badge-has-results { color: var(--accent-deep); background: var(--accent-bg); border-color: #dcd2f2; }
173 +.badge-pending, .badge-scaffolded { color: var(--muted); background: var(--surface-2); }
114 174
115 /* confidence levels — the atlas ladder and per-map badges */
116 .ladder { list-style: none; margin: 16px 0; padding: 0; }
117 .ladder-step { display: flex; align-items: flex-start; gap: 14px; padding: 9px 0; border-bottom: 1px solid var(--grid); }
175 +/* ---------------------------------------------------------------- ladder */
176 +.ladder { list-style: none; margin: 18px 0; padding: 0; }
177 +.ladder-step { display: flex; align-items: flex-start; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--grid); }
118 178 .ladder-step:last-child { border-bottom: 0; }
119 .ladder-step strong { display: block; font-size: 14.5px; }
179 +.ladder-step strong { display: block; font-size: 14.5px; font-family: var(--font-body); font-weight: 600; }
120 180 .ladder-desc { font-size: 13px; color: var(--muted); display: block; }
121 181 .ladder-num {
122 flex: 0 0 auto; width: 34px; height: 34px; border-radius: 8px; display: flex;
123 align-items: center; justify-content: center; font-weight: 700; font-size: 13px;
124 font-family: ui-monospace, Menlo, monospace; margin-top: 2px;
125 background: var(--page); border: 1px solid var(--grid); color: var(--muted);
126 }
127 .ladder-num.lv1 { background: #fdf3e2; border-color: #f3ddb0; color: #9a6a00; }
128 .ladder-num.lv2 { background: var(--accent-bg); border-color: #dcd2f2; color: var(--accent-dark); }
129 .ladder-num.lv3 { background: #eef7ee; border-color: #cfe8cf; color: var(--good-text); }
130 .badge-level-0 { color: var(--muted); background: var(--page); }
131 .badge-level-1 { color: #9a6a00; background: #fdf3e2; border-color: #f3ddb0; }
132 .badge-level-2 { color: var(--accent-dark); background: var(--accent-bg); border-color: #dcd2f2; }
133 .badge-level-3 { color: var(--good-text); background: #eef7ee; border-color: #cfe8cf; }
134
135 /* doc cards */
136 .page-title { font-size: 30px; letter-spacing: -0.02em; margin: 40px 0 8px; }
137 .section-title { font-size: 20px; margin: 32px 0 8px; }
138 .doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 16px 0; }
182 + flex: 0 0 auto; width: 36px; height: 36px; border-radius: 10px; display: flex;
183 + align-items: center; justify-content: center; font-weight: 650; font-size: 13px;
184 + font-family: var(--font-mono); margin-top: 2px;
185 + background: var(--surface-2); border: 1px solid var(--grid); color: var(--muted);
186 +}
187 +.ladder-num.lv1 { background: var(--warn-bg); border-color: #f3ddb0; color: var(--warn-text); }
188 +.ladder-num.lv2 { background: var(--accent-bg); border-color: #dcd2f2; color: var(--accent-deep); }
189 +.ladder-num.lv3 { background: var(--good-bg); border-color: #cfe8d8; color: var(--good-text); }
190 +.badge-level-0 { color: var(--muted); background: var(--surface-2); }
191 +.badge-level-1 { color: var(--warn-text); background: var(--warn-bg); border-color: #f3ddb0; }
192 +.badge-level-2 { color: var(--accent-deep); background: var(--accent-bg); border-color: #dcd2f2; }
193 +.badge-level-3 { color: var(--good-text); background: var(--good-bg); border-color: #cfe8d8; }
194 +
195 +/* ---------------------------------------------------------------- cards grid */
196 +.page-title { font-size: clamp(28px, 4vw, 36px); letter-spacing: -0.015em; margin: 44px 0 10px; }
197 +.section-title { font-size: 22px; margin: 36px 0 8px; }
198 +.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 18px 0; }
139 199 .doc-card {
140 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
141 padding: 18px 20px; color: var(--ink); display: block;
200 + background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius);
201 + padding: 20px 22px; color: var(--ink); display: block;
202 + box-shadow: var(--shadow-sm); transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
142 203 }
143 .doc-card:hover { border-color: var(--accent); text-decoration: none; }
204 +.doc-card:hover { border-color: var(--accent); text-decoration: none; transform: translateY(-2px); box-shadow: var(--shadow-md); }
144 205 .doc-card.disabled { opacity: 0.55; pointer-events: none; }
145 .doc-card h3 { margin: 0 0 6px; font-size: 16px; }
146 .doc-card p { margin: 0 0 8px; font-size: 13.5px; color: var(--ink-2); }
206 +.doc-card h3 { margin: 0 0 6px; font-size: 16.5px; }
207 +.doc-card p { margin: 0 0 10px; font-size: 13.5px; color: var(--ink-2); }
147 208 .runs { font-size: 12px; color: var(--muted); margin-left: 8px; }
148 209 .note-line { color: var(--ink-2); font-size: 14px; }
149 210
150 /* markdown body */
151 .doc { max-width: 860px; margin: 32px auto; }
152 .crumb { font-size: 13px; color: var(--muted); margin: 24px 0 4px; }
211 +/* ---------------------------------------------------------------- markdown */
212 +.doc { max-width: 880px; margin: 32px auto; }
213 +.crumb { font-size: 13px; color: var(--muted); margin: 26px 0 4px; font-family: var(--font-mono); }
153 214 .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 18px; }
154 215 .chip {
155 216 font-size: 12px; background: var(--surface); border: 1px solid var(--grid);
156 border-radius: 20px; padding: 3px 12px; color: var(--ink-2);
217 + border-radius: 999px; padding: 3px 12px; color: var(--ink-2);
157 218 }
158 .chip-k { color: var(--muted); margin-right: 6px; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
219 +.chip-k { color: var(--muted); margin-right: 6px; text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em; }
159 220 .md { overflow-wrap: break-word; }
160 .md h1 { font-size: 28px; letter-spacing: -0.02em; }
161 .md h2 { font-size: 21px; margin-top: 36px; border-bottom: 1px solid var(--grid); padding-bottom: 6px; }
162 .md h3 { font-size: 17px; margin-top: 28px; }
163 .md code { background: #f1f0ec; border-radius: 4px; padding: 1px 5px; font-size: 0.88em; }
221 +.md h1 { font-size: 30px; letter-spacing: -0.015em; }
222 +.md h2 { font-size: 22px; margin-top: 40px; border-bottom: 1px solid var(--grid); padding-bottom: 8px; }
223 +.md h3 { font-size: 17.5px; margin-top: 30px; }
224 +.md code { background: var(--surface-2); border-radius: 5px; padding: 1px 6px; font-size: 0.86em; }
164 225 .md pre code, .codeblock code { background: none; padding: 0; font-size: 13px; }
165 226 .codeblock, .md pre {
166 background: #f6f5f2; border: 1px solid var(--grid); border-radius: 8px;
167 padding: 14px 16px; overflow-x: auto; line-height: 1.5;
227 + background: var(--surface-2); border: 1px solid var(--grid); border-radius: 10px;
228 + padding: 15px 18px; overflow-x: auto; line-height: 1.55;
168 229 }
169 230 .md table { border-collapse: collapse; width: 100%; font-size: 14px; display: block; overflow-x: auto; }
170 .md th, .md td { border: 1px solid var(--grid); padding: 6px 10px; text-align: left; vertical-align: top; }
171 .md th { background: var(--page); }
172 .md blockquote { border-left: 3px solid var(--accent); margin-left: 0; padding-left: 16px; color: var(--ink-2); }
231 +.md th, .md td { border: 1px solid var(--grid); padding: 7px 11px; text-align: left; vertical-align: top; }
232 +.md th { background: var(--surface-2); font-family: var(--font-body); }
233 +.md blockquote {
234 + border-left: 3px solid var(--accent); margin-left: 0; padding: 4px 0 4px 18px;
235 + color: var(--ink-2); font-family: var(--font-display); font-style: italic; font-size: 1.03em;
236 +}
173 237
174 /* log */
175 .log-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
176 .log-entry h3 { font-size: 15px; margin: 0 0 8px; }
238 +/* ---------------------------------------------------------------- log */
239 +.log-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 22px; }
240 +.log-entry h3 { font-size: 15.5px; margin: 0 0 8px; }
177 241 .log-entry .md { font-size: 13.5px; color: var(--ink-2); }
178 242
179 /* results table / kv table / file lists */
180 .results-table, .kv-table { border-collapse: collapse; width: 100%; font-size: 14.5px; background: var(--surface); }
243 +/* ---------------------------------------------------------------- tables */
244 +.results-table, .kv-table {
245 + border-collapse: collapse; width: 100%; font-size: 14.5px; background: var(--surface);
246 + border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm);
247 +}
181 248 .results-table th, .results-table td, .kv-table th, .kv-table td {
182 border: 1px solid var(--grid); padding: 8px 12px; text-align: left;
249 + border: 1px solid var(--grid); padding: 9px 13px; text-align: left;
183 250 }
184 .results-table th { background: var(--page); }
185 .kv-table th { background: var(--page); width: 180px; }
251 +.results-table th { background: var(--surface-2); font-family: var(--font-body); }
252 +.kv-table th { background: var(--surface-2); width: 190px; }
253 +.table-scroll { overflow-x: auto; }
186 254 .file-list { list-style: none; padding: 0; }
187 .file-list li { padding: 6px 0; border-bottom: 1px solid var(--grid); }
255 +.file-list li { padding: 7px 0; border-bottom: 1px solid var(--grid); }
188 256 .tree { list-style: none; padding-left: 0; }
189 257 .tree ul { list-style: none; padding-left: 20px; }
190 258 .tree-root { margin-bottom: 14px; }
191 259 .tree li { padding: 2px 0; font-size: 14.5px; }
192 260 .mono-small { font-size: 12.5px; color: var(--muted); }
193 261 .file-card { padding: 0; overflow: hidden; }
194 .file-head { display: flex; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--grid); background: var(--page); }
262 +.file-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 10px 16px; border-bottom: 1px solid var(--grid); background: var(--surface-2); }
195 263 .file-card .codeblock { border: 0; border-radius: 0; margin: 0; }
196 264
197 /* charts (server-rendered SVG, used once atlas data exists) */
265 +/* ---------------------------------------------------------------- comments */
266 +.comment-form { display: grid; gap: 14px; max-width: 640px; }
267 +.comment-form label { font-size: 13.5px; font-weight: 600; color: var(--ink-2); display: block; margin-bottom: 5px; }
268 +.comment-form input[type="text"], .comment-form textarea {
269 + width: 100%; font: inherit; color: var(--ink);
270 + background: var(--surface); border: 1px solid var(--baseline); border-radius: 10px;
271 + padding: 11px 14px; transition: border-color 0.15s, box-shadow 0.15s;
272 +}
273 +.comment-form input:focus, .comment-form textarea:focus {
274 + outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow);
275 +}
276 +.comment-form textarea { min-height: 130px; resize: vertical; }
277 +.hp-field { position: absolute; left: -9999px; top: -9999px; height: 1px; width: 1px; overflow: hidden; }
278 +.btn {
279 + display: inline-block; font: inherit; font-weight: 650; font-size: 15px;
280 + background: var(--accent-dark); color: #fff; border: 0; border-radius: 999px;
281 + padding: 11px 26px; cursor: pointer; justify-self: start;
282 + box-shadow: var(--shadow-sm); transition: background 0.15s, transform 0.15s;
283 +}
284 +.btn:hover { background: var(--accent-deep); transform: translateY(-1px); }
285 +.flash { border-radius: 10px; padding: 12px 16px; font-size: 14.5px; margin: 14px 0; }
286 +.flash-ok { background: var(--good-bg); color: var(--good-text); border: 1px solid #cfe8d8; }
287 +.flash-err { background: #fdecec; color: #a03030; border: 1px solid #f2ccc9; }
288 +.comment-list { list-style: none; padding: 0; margin: 8px 0 0; }
289 +.comment {
290 + background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius);
291 + padding: 16px 20px; margin-bottom: 14px; box-shadow: var(--shadow-sm);
292 +}
293 +.comment-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
294 +.comment-name { font-weight: 650; font-size: 14.5px; }
295 +.comment-date { font-size: 12px; color: var(--muted); font-family: var(--font-mono); }
296 +.comment-body { font-size: 14.5px; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: break-word; }
297 +
298 +/* ---------------------------------------------------------------- charts */
198 299 .chart-fig { margin: 18px 0 8px; position: relative; }
199 300 .chart-fig svg { width: 100%; height: auto; }
200 301 .chart-fig .grid { stroke: var(--grid); stroke-width: 1; }
201 302 .chart-fig .axis { stroke: var(--baseline); stroke-width: 1; }
202 .chart-fig .tick { fill: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, monospace; }
303 +.chart-fig .tick { fill: var(--muted); font-size: 11px; font-family: var(--font-mono); }
203 304 .chart-fig .axis-title { fill: var(--ink-2); font-size: 12px; }
204 305 .chart-fig .series-label { font-size: 12px; font-weight: 600; }
205 306 .chart-fig .pt { cursor: pointer; }
206 307 .chart-fig figcaption { font-size: 12.5px; color: var(--muted); margin-top: 4px; }
207 308 .chart-tip {
208 309 position: absolute; pointer-events: none; background: var(--ink); color: #fff;
209 font-size: 12.5px; padding: 5px 10px; border-radius: 6px; white-space: nowrap; z-index: 5;
310 + font-size: 12.5px; padding: 5px 10px; border-radius: 8px; white-space: nowrap; z-index: 5;
210 311 }
211 312
212 /* hljs light theme (subset, GitHub-like) */
313 +/* ---------------------------------------------------------------- hljs */
213 314 .hljs { color: #24292e; }
214 315 .hljs-keyword, .hljs-meta .hljs-keyword { color: #d73a49; }
215 316 .hljs-string, .hljs-attr { color: #032f62; }
@@ -221,10 +322,71 @@ code, pre, .mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monos
221 322 .hljs-params { color: #24292e; }
222 323 .hljs-variable, .hljs-template-variable { color: #e36209; }
223 324
224 /* footer */
325 +/* ---------------------------------------------------------------- footer */
225 326 .site-footer {
226 border-top: 1px solid var(--grid); margin-top: 48px; padding: 20px 0 32px;
227 background: var(--surface); font-size: 13px; color: var(--ink-2);
327 + border-top: 1px solid var(--grid); margin-top: 64px; padding: 44px 0 28px;
328 + background: var(--surface); font-size: 14px; color: var(--ink-2);
329 +}
330 +.footer-grid { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; }
331 +.footer-col { display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
332 +.footer-col h4 {
333 + margin: 0 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em;
334 + color: var(--muted); font-family: var(--font-body); font-weight: 650;
335 +}
336 +.footer-col a { color: var(--ink-2); font-size: 13.5px; }
337 +.footer-col a:hover { color: var(--accent-deep); }
338 +.footer-brand p { margin: 6px 0 0; font-size: 13px; color: var(--muted); max-width: 300px; }
339 +.brand-foot {
340 + font-family: var(--font-display); font-weight: 600; font-size: 18px; color: var(--ink);
341 + display: inline-flex; align-items: center; gap: 8px;
342 +}
343 +.brand-foot .logo { width: 22px; height: 22px; }
344 +.footer-meta { color: var(--muted); font-size: 12px; font-family: var(--font-mono); }
345 +.footer-bottom {
346 + margin-top: 30px; padding-top: 18px; border-top: 1px solid var(--grid);
347 + font-size: 12.5px; color: var(--muted);
348 +}
349 +
350 +/* ============================================================ responsive */
351 +@media (max-width: 980px) {
352 + .tiles { grid-template-columns: repeat(2, 1fr); }
353 + .split { grid-template-columns: 1fr; }
354 + .footer-grid { grid-template-columns: 1fr 1fr; }
355 +}
356 +
357 +@media (max-width: 880px) {
358 + .nav-toggle { display: flex; }
359 + .site-nav {
360 + display: none;
361 + position: absolute; top: 62px; left: 0; right: 0;
362 + flex-direction: column; gap: 2px;
363 + background: var(--surface);
364 + border-bottom: 1px solid var(--grid);
365 + box-shadow: var(--shadow-md);
366 + padding: 10px 16px 16px;
367 + }
368 + .site-nav.open { display: flex; }
369 + .site-nav a { padding: 13px 14px; border-radius: 10px; font-size: 16px; }
370 + .site-nav a + a { border-top: 1px solid var(--surface-2); border-radius: 0 0 10px 10px; }
371 +}
372 +
373 +@media (max-width: 560px) {
374 + body { font-size: 15.5px; }
375 + .wrap { padding: 0 18px; }
376 + .hero { padding: 44px 0 8px; }
377 + .lede { font-size: 16.5px; }
378 + .lede-eq code { font-size: 11px; padding: 8px 10px; }
379 + .tiles { grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 28px 0; }
380 + .tile { padding: 16px 18px; }
381 + .tile-value { font-size: 30px; }
382 + .card { padding: 20px 18px; border-radius: 12px; }
383 + .doc-grid { grid-template-columns: 1fr; }
384 + .page-title { margin: 30px 0 8px; }
385 + .phase strong { font-size: 13px; }
386 + .phase .badge { display: none; } /* dot already carries the status */
387 + .footer-grid { grid-template-columns: 1fr; gap: 24px; }
388 + .results-table, .kv-table { display: block; overflow-x: auto; }
389 + .md h2 { font-size: 20px; }
390 + .codeblock, .md pre { padding: 12px 12px; font-size: 12px; }
391 + .comment { padding: 14px 16px; }
228 392 }
229 .site-footer .wrap { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
230 .footer-meta { color: var(--muted); }
modified site/server.js +52 −0
@@ -16,11 +16,14 @@ const path = require("path");
16 16 const express = require("express");
17 17 const C = require("./lib/content");
18 18 const R = require("./lib/render");
19 +const Comments = require("./lib/comments");
19 20
20 21 const app = express();
21 22 const PORT = process.env.PORT || 8140;
22 23
24 +app.set("trust proxy", true); // behind ngrok — X-Forwarded-For carries the real IP
23 25 app.use("/static", express.static(path.join(__dirname, "public"), { maxAge: "1h" }));
26 +app.use(express.urlencoded({ extended: false, limit: "16kb" }));
24 27
25 28 function page(res, opts) {
26 29 res.send(R.layout({ ...opts, buildInfo: C.buildInfo() }));
@@ -374,6 +377,55 @@ capture-feasibility frontier (Experiment H).</p>
374 377 page(res, { title: "About", active: "About", body });
375 378 });
376 379
380 +// ---------------------------------------------------------------- comments
381 +function commentsPage(res, { flash = "", flashKind = "ok" } = {}) {
382 + const items = Comments.list();
383 + const listHtml = items.length
384 + ? `<ul class="comment-list">` + items.map((c) => `<li class="comment">
385 + <div class="comment-head"><span class="comment-name">${R.esc(c.name)}</span>
386 + <span class="comment-date">${R.esc(String(c.created).slice(0, 10))}</span></div>
387 + <div class="comment-body">${R.esc(c.message)}</div></li>`).join("") + `</ul>`
388 + : `<p class="note-line">No comments yet — be the first.</p>`;
389 + const body = `<h1 class="page-title">Comments</h1>
390 +<p class="lede-small">Questions, critiques, replication reports, pointers to related work — all welcome.
391 +Methodological challenges are especially valued: this project publishes its negative results,
392 +and a comment that breaks a map is a contribution.</p>
393 +${flash ? `<div class="flash flash-${flashKind}">${R.esc(flash)}</div>` : ""}
394 +<section class="card">
395 + <h2>Leave a comment</h2>
396 + <form class="comment-form" method="POST" action="/comments">
397 + <div><label for="c-name">Name (optional)</label>
398 + <input type="text" id="c-name" name="name" maxlength="60" autocomplete="name" placeholder="Your name"></div>
399 + <div class="hp-field" aria-hidden="true"><label for="c-website">Website</label>
400 + <input type="text" id="c-website" name="website" tabindex="-1" autocomplete="off"></div>
401 + <div><label for="c-message">Comment</label>
402 + <textarea id="c-message" name="message" maxlength="2000" required
403 + placeholder="Your comment — plain text, max 2000 characters"></textarea></div>
404 + <button class="btn" type="submit">Post comment</button>
405 + </form>
406 +</section>
407 +<h2 class="section-title">${items.length ? items.length + " comment" + (items.length > 1 ? "s" : "") : "Comments"}</h2>
408 +${listHtml}`;
409 + page(res, { title: "Comments", active: "Comments", body });
410 +}
411 +
412 +app.get("/comments", (req, res) => {
413 + const flash = req.query.ok === "1" ? "Thank you — your comment is published." :
414 + req.query.err ? String(req.query.err) : "";
415 + commentsPage(res, { flash, flashKind: req.query.ok === "1" ? "ok" : "err" });
416 +});
417 +
418 +app.post("/comments", (req, res) => {
419 + const out = Comments.add({
420 + name: req.body.name,
421 + message: req.body.message,
422 + honeypot: req.body.website,
423 + ip: req.ip,
424 + });
425 + if (out.ok) return res.redirect(303, "/comments?ok=1");
426 + return res.redirect(303, "/comments?err=" + encodeURIComponent(out.error || "Could not post."));
427 +});
428 +
377 429 // ---------------------------------------------------------------- misc
378 430 app.get("/health", (req, res) => res.json({ ok: true, app: "modelmap-web", author: "Simon-Pierre Boucher", website: "https://modelmap.io" }));
379 431
380 432