SPB Git

spb/anomaly-atlas Public License

Systematic discovery & rigorous validation of statistical anomalies in open HF market data (hfmarketdata.io) — pre-registered, artifact-null-driven, fully reproducible. Live atlas: www.anomaly-atlas.io

Python 61.4% JavaScript 28.7% CSS 8.6% Shell 0.7% Makefile 0.5%

Web platform v2: editorial redesign, mobile-first + CSS-only menu, comments, SVG logo

- style.css rewritten: Fraunces/Inter/JetBrains Mono, warm paper palette,
  responsive grids, overflow-safe tables/code, light hljs theme
- CSS-only hamburger menu <=820px (opaque panel, no backdrop-filter)
- /comments: public guestbook — honeypot + 30s/IP throttle + HTML escaping,
  storage in ~/.anomaly-atlas/comments.json (survives rsync --delete)
- logo.svg: atlas graticule + data line + circled outlier; favicon + brand

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

Showing 4 changed files with +513 and −252

modified web/lib/render.js +10 −2
@@ -143,6 +143,7 @@ const NAV = [
143 143 ["/experiments", "Experiments"],
144 144 ["/results", "Results"],
145 145 ["/code", "Code"],
146 + ["/comments", "Comments"],
146 147 ["/about", "About"],
147 148 ];
148 149
@@ -160,13 +161,20 @@ function layout({ title, active, body, buildInfo = {} }) {
160 161 <meta name="viewport" content="width=device-width, initial-scale=1">
161 162 <title>${esc(title)} · anomaly-atlas</title>
162 163 <meta name="description" content="anomaly-atlas — systematic discovery & rigorous validation of statistical anomalies in open HF market data (hfmarketdata.io). Research by Simon-Pierre Boucher. Not investment advice.">
164 +<meta name="theme-color" content="#faf8f4">
165 +<link rel="preconnect" href="https://fonts.googleapis.com">
166 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
167 +<link rel="stylesheet" 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..700&family=JetBrains+Mono:wght@400..600&display=swap">
163 168 <link rel="stylesheet" href="/static/style.css">
164 <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>">
169 +<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
170 +<link rel="apple-touch-icon" href="/static/logo.svg">
165 171 </head>
166 172 <body>
167 173 <header class="site-header">
168 174 <div class="wrap header-row">
169 <a class="brand" href="/">anomaly<span class="brand-dim">-atlas</span></a>
175 + <a class="brand" href="/"><img class="brand-mark" src="/static/logo.svg" alt="" width="30" height="30">anomaly<span class="brand-dim">-atlas</span></a>
176 + <input type="checkbox" id="nav-toggle" class="nav-toggle" aria-label="Open menu">
177 + <label for="nav-toggle" class="nav-burger" aria-hidden="true"><span></span><span></span><span></span></label>
170 178 <nav class="site-nav">${nav}</nav>
171 179 </div>
172 180 </header>
added web/public/logo.svg +35 −0
@@ -0,0 +1,35 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="anomaly-atlas logo">
2 + <!-- ==========================================================================
3 + Project : anomaly-atlas
4 + File : web/public/logo.svg
5 + Purpose : Logo mark & favicon — atlas graticule + data line + circled outlier
6 + Author : Simon-Pierre Boucher
7 + Contact : contact@spboucher.ai
8 + Data src : hfmarketdata.io (sole data source)
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 + <rect width="64" height="64" rx="14" fill="#1a1c20"/>
15 + <!-- atlas: globe graticule -->
16 + <g stroke="#9aa0a8" stroke-width="1.1" fill="none" opacity=".5">
17 + <circle cx="32" cy="32" r="20"/>
18 + <ellipse cx="32" cy="32" rx="9" ry="20"/>
19 + <path d="M13.3 24.5 h37.4"/>
20 + <path d="M13.3 39.5 h37.4"/>
21 + </g>
22 + <!-- the data line (what the series does) -->
23 + <path d="M11 42 L19 38.5 L25 41 L31 35 L37 38 L45 31 L53 33.5"
24 + stroke="#7fa4ff" stroke-width="2.6" fill="none"
25 + stroke-linecap="round" stroke-linejoin="round"/>
26 + <g fill="#7fa4ff">
27 + <circle cx="19" cy="38.5" r="1.6"/>
28 + <circle cx="31" cy="35" r="1.6"/>
29 + <circle cx="45" cy="31" r="1.6"/>
30 + </g>
31 + <!-- the anomaly: the point the line cannot explain, circled for the atlas -->
32 + <circle cx="41" cy="17.5" r="3.2" fill="#ffb15c"/>
33 + <circle cx="41" cy="17.5" r="7" fill="none" stroke="#ffb15c"
34 + stroke-width="1.4" stroke-dasharray="3.2 2.6"/>
35 +</svg>
modified web/public/style.css +379 −250
@@ -1,7 +1,7 @@
1 1 /* ============================================================================
2 2 * Project : anomaly-atlas
3 3 * File : web/public/style.css
4 * Purpose : Light-theme styling for the research showcase platform
4 + * Purpose : Editorial light theme, mobile-first responsive, CSS-only menu
5 5 * Author : Simon-Pierre Boucher
6 6 * Contact : contact@spboucher.ai
7 7 * Data src : hfmarketdata.io (sole data source)
@@ -13,299 +13,428 @@
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);
24 --accent: #2a78d6;
25 --accent-dark: #1c5cab;
26 --good: #0ca30c;
27 --good-text: #006300;
28 --warn: #eda100;
16 + --paper: #faf8f4;
17 + --card: #fffdf9;
18 + --ink: #1a1c20;
19 + --muted: #5d6167;
20 + --faint: #8b8f96;
21 + --hairline: #e5e1d6;
22 + --hairline-2: #d8d3c4;
23 + --accent: #2456c4;
24 + --accent-ink: #173a85;
25 + --accent-soft: #e8eefb;
26 + --warn-bg: #fdf4e3;
27 + --warn-border: #ecd9b0;
28 + --warn-ink: #6b5322;
29 + --ok: #1e7a4d;
30 + --ok-soft: #e7f4ec;
31 + --pending: #8b8f96;
32 + --pending-soft: #efede6;
33 + --code-bg: #f3f1ea;
34 + --shadow: 0 1px 2px rgba(26, 28, 32, .04), 0 8px 24px -12px rgba(26, 28, 32, .10);
35 + --radius: 14px;
36 + --font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
37 + --font-text: "Inter", -apple-system, "SF Pro Text", "Segoe UI", sans-serif;
38 + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
29 39 }
30 40
31 41 * { box-sizing: border-box; }
32 html { -webkit-text-size-adjust: 100%; }
42 +html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
33 43 body {
34 44 margin: 0;
35 font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
36 background: var(--page);
45 + background:
46 + radial-gradient(1200px 500px at 85% -100px, #f2ede1 0%, transparent 60%),
47 + var(--paper);
37 48 color: var(--ink);
38 line-height: 1.6;
49 + font-family: var(--font-text);
39 50 font-size: 16px;
51 + line-height: 1.6;
52 + min-height: 100vh;
53 + display: flex;
54 + flex-direction: column;
40 55 }
41 .wrap { max-width: 1080px; margin: 0 auto; padding: 0 24px; }
42 a { color: var(--accent-dark); text-decoration: none; }
43 a:hover { text-decoration: underline; }
44 code, pre, .mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
56 +main { flex: 1; width: 100%; }
57 +.wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; }
58 +a { color: var(--accent); text-decoration: none; }
59 +a:hover { text-decoration: underline; text-underline-offset: 3px; }
60 +::selection { background: var(--accent-soft); }
45 61
46 /* header */
62 +/* ---------------------------------------------------------------- header */
47 63 .site-header {
48 background: var(--surface);
49 border-bottom: 1px solid var(--grid);
50 position: sticky; top: 0; z-index: 10;
51 }
52 .header-row { display: flex; align-items: center; justify-content: space-between; height: 56px; }
53 .brand { font-weight: 700; font-size: 18px; color: var(--ink); letter-spacing: -0.02em; }
54 .brand-dim { color: var(--muted); font-weight: 500; }
55 .site-nav { display: flex; gap: 4px; flex-wrap: wrap; }
64 + position: sticky; top: 0; z-index: 50;
65 + background: var(--paper);
66 + border-bottom: 1px solid var(--hairline);
67 +}
68 +.header-row {
69 + position: relative;
70 + display: flex; align-items: center; justify-content: space-between;
71 + gap: 16px; min-height: 60px;
72 +}
73 +.brand {
74 + display: flex; align-items: center; gap: 10px;
75 + font-family: var(--font-display);
76 + font-weight: 640; font-size: 1.35rem; letter-spacing: -.01em;
77 + color: var(--ink);
78 +}
79 +.brand-mark { width: 30px; height: 30px; border-radius: 8px; flex: none; }
80 +.brand:hover { text-decoration: none; }
81 +.brand-dim { color: var(--faint); font-weight: 420; font-style: italic; }
82 +
83 +.site-nav { display: flex; gap: 2px; flex-wrap: wrap; }
56 84 .site-nav a {
57 padding: 6px 12px; border-radius: 6px; color: var(--ink-2); font-size: 14px; font-weight: 500;
85 + color: var(--muted); font-size: .92rem; font-weight: 520;
86 + padding: 7px 12px; border-radius: 999px;
87 +}
88 +.site-nav a:hover { color: var(--ink); background: var(--pending-soft); text-decoration: none; }
89 +.site-nav a.active { color: var(--accent-ink); background: var(--accent-soft); }
90 +
91 +/* CSS-only mobile menu: opaque dropdown panel, no backdrop-filter anywhere */
92 +.nav-toggle { display: none; }
93 +.nav-burger { display: none; cursor: pointer; padding: 10px; margin: -10px; }
94 +.nav-burger span {
95 + display: block; width: 22px; height: 2px; background: var(--ink);
96 + margin: 5px 0; border-radius: 2px; transition: transform .25s, opacity .2s;
97 +}
98 +@media (max-width: 820px) {
99 + .nav-burger { display: block; }
100 + .site-nav {
101 + display: none;
102 + position: absolute; left: -20px; right: -20px; top: 100%;
103 + flex-direction: column; gap: 0;
104 + background: var(--paper);
105 + border-bottom: 1px solid var(--hairline-2);
106 + box-shadow: 0 18px 30px -18px rgba(26, 28, 32, .25);
107 + padding: 6px 12px 14px;
108 + }
109 + .nav-toggle:checked ~ .site-nav { display: flex; }
110 + .site-nav a {
111 + font-size: 1.05rem; padding: 13px 12px; border-radius: 10px;
112 + border-bottom: 1px solid var(--hairline);
113 + }
114 + .site-nav a:last-child { border-bottom: 0; }
115 + .nav-toggle:checked ~ .nav-burger span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
116 + .nav-toggle:checked ~ .nav-burger span:nth-child(2) { opacity: 0; }
117 + .nav-toggle:checked ~ .nav-burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
58 118 }
59 .site-nav a:hover { background: var(--page); text-decoration: none; color: var(--ink); }
60 .site-nav a.active { color: var(--accent-dark); background: #edf3fb; }
61 119
62 /* hero */
63 .hero { padding: 56px 0 8px; }
120 +/* ------------------------------------------------------------ honesty bar */
121 +.honesty-banner {
122 + background: var(--warn-bg);
123 + border-bottom: 1px solid var(--warn-border);
124 + color: var(--warn-ink);
125 + font-size: .8rem; line-height: 1.5;
126 + padding: 9px 0;
127 +}
128 +.honesty-banner strong { color: #4a3a14; }
129 +
130 +/* ------------------------------------------------------------------ hero */
131 +.hero { padding: 56px 0 8px; max-width: 60rem; }
64 132 .kicker {
65 text-transform: uppercase; letter-spacing: 0.08em; font-size: 12px; font-weight: 600;
66 color: var(--accent-dark); margin: 0 0 12px;
133 + font-family: var(--font-mono); font-size: .74rem; letter-spacing: .14em;
134 + text-transform: uppercase; color: var(--accent-ink); margin: 0 0 14px;
135 +}
136 +.hero h1 {
137 + font-family: var(--font-display);
138 + font-size: clamp(1.9rem, 5.4vw, 3.3rem);
139 + line-height: 1.08; letter-spacing: -.02em; font-weight: 620;
140 + margin: 0 0 18px;
67 141 }
68 .hero h1 { font-size: 40px; line-height: 1.15; letter-spacing: -0.02em; margin: 0 0 16px; }
69 .lede { font-size: 18px; color: var(--ink-2); max-width: 760px; margin: 0 0 12px; }
70 .lede-small { color: var(--ink-2); max-width: 760px; }
142 +.lede { font-size: 1.08rem; color: var(--muted); max-width: 46rem; margin: 0 0 14px; }
143 +.lede em { color: var(--ink); }
144 +.lede-eq { margin: 18px 0 0; }
71 145 .lede-eq code {
72 font-size: 13px; background: var(--surface); border: 1px solid var(--grid);
73 border-radius: 6px; padding: 6px 10px; color: var(--ink-2); display: inline-block;
146 + display: inline-block; font-family: var(--font-mono); font-size: .8rem;
147 + background: var(--code-bg); border: 1px solid var(--hairline);
148 + border-radius: 10px; padding: 9px 14px; color: var(--ink);
149 + overflow-x: auto; max-width: 100%;
150 +}
151 +.lede-small { color: var(--muted); max-width: 46rem; }
152 +.page-title {
153 + font-family: var(--font-display); font-weight: 620; letter-spacing: -.015em;
154 + font-size: clamp(1.6rem, 4vw, 2.3rem); margin: 42px 0 10px;
155 +}
156 +.section-title {
157 + font-family: var(--font-display); font-weight: 600;
158 + font-size: 1.35rem; margin: 40px 0 6px;
74 159 }
75 160
76 /* stat tiles */
77 .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin: 32px 0; }
161 +/* ----------------------------------------------------------------- tiles */
162 +.tiles {
163 + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
164 + gap: 12px; margin: 34px 0;
165 +}
78 166 .tile {
79 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
80 padding: 20px 22px; display: flex; flex-direction: column; gap: 2px;
167 + background: var(--card); border: 1px solid var(--hairline);
168 + border-radius: var(--radius); box-shadow: var(--shadow);
169 + padding: 18px 18px 14px; display: flex; flex-direction: column; gap: 2px;
170 +}
171 +.tile-value {
172 + font-family: var(--font-display); font-size: 2.1rem; font-weight: 620;
173 + line-height: 1; letter-spacing: -.02em;
81 174 }
82 .tile-value { font-size: 34px; font-weight: 700; letter-spacing: -0.02em; }
83 .tile-denom { font-size: 20px; color: var(--muted); font-weight: 500; }
84 .tile-label { color: var(--ink-2); font-size: 13.5px; }
175 +.tile-denom { font-size: 1.15rem; color: var(--faint); font-weight: 480; }
176 +.tile-label { font-size: .8rem; color: var(--muted); }
85 177
86 /* layout blocks */
87 .split { display: grid; grid-template-columns: 3fr 2fr; gap: 20px; margin: 8px 0 20px; }
88 @media (max-width: 880px) { .split { grid-template-columns: 1fr; } .hero h1 { font-size: 30px; } }
178 +/* ----------------------------------------------------------------- cards */
179 +.split { display: grid; grid-template-columns: 1.2fr 1fr; gap: 16px; margin: 8px 0 16px; }
180 +@media (max-width: 900px) { .split { grid-template-columns: 1fr; } }
89 181 .card {
90 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
91 padding: 24px 26px; margin-bottom: 20px;
182 + background: var(--card); border: 1px solid var(--hairline);
183 + border-radius: var(--radius); box-shadow: var(--shadow);
184 + padding: 24px; margin: 16px 0; min-width: 0;
92 185 }
93 .card h2 { margin-top: 0; font-size: 20px; letter-spacing: -0.01em; }
94 .more { font-weight: 600; font-size: 14px; }
186 +.card h2 {
187 + font-family: var(--font-display); font-weight: 620; letter-spacing: -.01em;
188 + font-size: 1.3rem; margin: 0 0 12px;
189 +}
190 +.card-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
191 +.more { font-weight: 560; font-size: .92rem; white-space: nowrap; }
95 192
96 /* phases */
97 .phases { list-style: none; margin: 0; padding: 0; }
98 .phase { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--grid); }
193 +/* ---------------------------------------------------------------- phases */
194 +.phases { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
195 +.phase {
196 + display: flex; align-items: flex-start; gap: 12px;
197 + padding: 10px 4px; border-bottom: 1px dashed var(--hairline);
198 +}
99 199 .phase:last-child { border-bottom: 0; }
100 .phase strong { display: block; font-size: 14.5px; }
101 .phase-detail { font-size: 13px; color: var(--muted); }
102 .phase-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 7px; flex: 0 0 auto; background: var(--baseline); }
103 .phase.done .phase-dot { background: var(--good); }
200 +.phase > div { flex: 1; min-width: 0; }
201 +.phase strong { display: block; font-size: .92rem; font-weight: 600; }
202 +.phase-detail { font-size: .8rem; color: var(--muted); }
203 +.phase-dot {
204 + width: 9px; height: 9px; border-radius: 50%; margin-top: 7px; flex: none;
205 + background: var(--pending);
206 +}
207 +.phase.done .phase-dot { background: var(--ok); }
104 208 .phase.in-progress .phase-dot { background: var(--accent); }
105 .phase .badge { margin-left: auto; }
209 +
210 +/* ---------------------------------------------------------------- badges */
106 211 .badge {
107 font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 20px;
108 white-space: nowrap; align-self: center; border: 1px solid var(--grid); color: var(--ink-2);
212 + display: inline-block; font-size: .7rem; font-weight: 640;
213 + letter-spacing: .04em; text-transform: uppercase;
214 + padding: 3px 10px; border-radius: 999px; white-space: nowrap;
215 + background: var(--pending-soft); color: var(--muted);
109 216 }
110 .badge-done { color: var(--good-text); background: #eef7ee; border-color: #cfe8cf; }
111 .badge-in-progress, .badge-has-results { color: var(--accent-dark); background: #edf3fb; border-color: #cfe0f5; }
112 .badge-pending, .badge-scaffolded { color: var(--muted); background: var(--page); }
113
114 /* doc cards */
115 .page-title { font-size: 30px; letter-spacing: -0.02em; margin: 40px 0 8px; }
116 .section-title { font-size: 20px; margin: 32px 0 8px; }
117 .doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 16px 0; }
118 .doc-card {
119 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
120 padding: 18px 20px; color: var(--ink); display: block;
121 }
122 .doc-card:hover { border-color: var(--accent); text-decoration: none; }
123 .doc-card.disabled { opacity: 0.55; pointer-events: none; }
124 .doc-card h3 { margin: 0 0 6px; font-size: 16px; }
125 .doc-card p { margin: 0 0 8px; font-size: 13.5px; color: var(--ink-2); }
126 .runs { font-size: 12px; color: var(--muted); margin-left: 8px; }
127 .note-line { color: var(--ink-2); font-size: 14px; }
128
129 /* markdown body */
130 .doc { max-width: 860px; margin: 32px auto; }
131 .crumb { font-size: 13px; color: var(--muted); margin: 24px 0 4px; }
132 .chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 18px; }
133 .chip {
134 font-size: 12px; background: var(--surface); border: 1px solid var(--grid);
135 border-radius: 20px; padding: 3px 12px; color: var(--ink-2);
136 }
137 .chip-k { color: var(--muted); margin-right: 6px; text-transform: uppercase; font-size: 10px; letter-spacing: 0.05em; }
138 .md { overflow-wrap: break-word; }
139 .md h1 { font-size: 28px; letter-spacing: -0.02em; }
140 .md h2 { font-size: 21px; margin-top: 36px; border-bottom: 1px solid var(--grid); padding-bottom: 6px; }
141 .md h3 { font-size: 17px; margin-top: 28px; }
142 .md code { background: #f1f0ec; border-radius: 4px; padding: 1px 5px; font-size: 0.88em; }
143 .md pre code, .codeblock code { background: none; padding: 0; font-size: 13px; }
144 .codeblock, .md pre {
145 background: #f6f5f2; border: 1px solid var(--grid); border-radius: 8px;
146 padding: 14px 16px; overflow-x: auto; line-height: 1.5;
147 }
148 .md table { border-collapse: collapse; width: 100%; font-size: 14px; display: block; overflow-x: auto; }
149 .md th, .md td { border: 1px solid var(--grid); padding: 6px 10px; text-align: left; vertical-align: top; }
150 .md th { background: var(--page); }
151 .md blockquote { border-left: 3px solid var(--accent); margin-left: 0; padding-left: 16px; color: var(--ink-2); }
152
153 /* log */
154 .log-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
155 .log-entry h3 { font-size: 15px; margin: 0 0 8px; }
156 .log-entry .md { font-size: 13.5px; color: var(--ink-2); }
217 +.badge-done { background: var(--ok-soft); color: var(--ok); }
218 +.badge-in-progress { background: var(--accent-soft); color: var(--accent-ink); }
219 +.badge-pending { background: var(--pending-soft); color: var(--pending); }
220 +.badge-level-0 { background: var(--pending-soft); color: var(--muted); }
221 +.badge-level-1 { background: var(--accent-soft); color: var(--accent-ink); }
222 +.badge-level-2 { background: var(--ok-soft); color: var(--ok); }
223 +.badge-level-3 { background: #14331f; color: #d9f5e3; }
157 224
158 /* results table / kv table / file lists */
159 .results-table, .kv-table { border-collapse: collapse; width: 100%; font-size: 14.5px; background: var(--surface); }
160 .results-table th, .results-table td, .kv-table th, .kv-table td {
161 border: 1px solid var(--grid); padding: 8px 12px; text-align: left;
162 }
163 .results-table th { background: var(--page); }
164 .kv-table th { background: var(--page); width: 180px; }
165 .file-list { list-style: none; padding: 0; }
166 .file-list li { padding: 6px 0; border-bottom: 1px solid var(--grid); }
167 .tree { list-style: none; padding-left: 0; }
168 .tree ul { list-style: none; padding-left: 20px; }
169 .tree-root { margin-bottom: 14px; }
170 .tree li { padding: 2px 0; font-size: 14.5px; }
171 .mono-small { font-size: 12.5px; color: var(--muted); }
172 .file-card { padding: 0; overflow: hidden; }
173 .file-head { display: flex; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--grid); background: var(--page); }
174 .file-card .codeblock { border: 0; border-radius: 0; margin: 0; }
175
176 /* chart */
177 .chart-fig { margin: 18px 0 8px; position: relative; }
178 .chart-fig svg { width: 100%; height: auto; }
179 .chart-fig .grid { stroke: var(--grid); stroke-width: 1; }
180 .chart-fig .axis { stroke: var(--baseline); stroke-width: 1; }
181 .chart-fig .tick { fill: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, monospace; }
182 .chart-fig .axis-title { fill: var(--ink-2); font-size: 12px; }
183 .chart-fig .series-label { font-size: 12px; font-weight: 600; }
184 .chart-fig .pt { cursor: pointer; }
185 .chart-fig figcaption { font-size: 12.5px; color: var(--muted); margin-top: 4px; }
186 .chart-tip {
187 position: absolute; pointer-events: none; background: var(--ink); color: #fff;
188 font-size: 12.5px; padding: 5px 10px; border-radius: 6px; white-space: nowrap; z-index: 5;
225 +/* --------------------------------------------------------------- doc grid */
226 +.doc-grid {
227 + display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
228 + gap: 12px; margin: 18px 0;
189 229 }
190
191 /* hljs light theme (subset, GitHub-like) */
192 .hljs { color: #24292e; }
193 .hljs-keyword, .hljs-meta .hljs-keyword { color: #d73a49; }
194 .hljs-string, .hljs-attr { color: #032f62; }
195 .hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
196 .hljs-number, .hljs-literal { color: #005cc5; }
197 .hljs-title, .hljs-function .hljs-title { color: #6f42c1; }
198 .hljs-built_in, .hljs-type { color: #e36209; }
199 .hljs-section { color: #005cc5; font-weight: 600; }
200 .hljs-params { color: #24292e; }
201 .hljs-variable, .hljs-template-variable { color: #e36209; }
202
203 /* footer */
204 .site-footer {
205 border-top: 1px solid var(--grid); margin-top: 48px; padding: 20px 0 32px;
206 background: var(--surface); font-size: 13px; color: var(--ink-2);
230 +.doc-card {
231 + display: flex; flex-direction: column; gap: 6px; align-items: flex-start;
232 + background: var(--card); border: 1px solid var(--hairline);
233 + border-radius: var(--radius); box-shadow: var(--shadow);
234 + padding: 18px; color: var(--ink); min-width: 0;
235 + transition: transform .12s ease, border-color .12s ease;
207 236 }
208 .site-footer .wrap { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
209 .footer-meta { color: var(--muted); }
237 +.doc-card:hover { text-decoration: none; transform: translateY(-2px); border-color: var(--hairline-2); }
238 +.doc-card h3 { margin: 0; font-size: 1.02rem; font-weight: 640; letter-spacing: -.01em; }
239 +.doc-card p { margin: 0; font-size: .85rem; color: var(--muted); }
240 +.doc-card.disabled { opacity: .55; pointer-events: none; }
241 +.runs { font-size: .75rem; color: var(--faint); }
210 242
211 /* ---- professional document view: header, TOC, anchors -------------------- */
212 .doc-head { margin: 28px 0 6px; }
243 +/* --------------------------------------------------------------- markdown */
244 +.crumb { font-size: .82rem; color: var(--faint); margin: 26px 0 4px; font-family: var(--font-mono); }
245 +.doc-head { margin: 8px 0 14px; }
213 246 .doc-head-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
214 .doc-head h1 { font-size: 30px; letter-spacing: -0.02em; margin: 0; text-transform: none; }
215 .doc-head h1::first-letter { text-transform: uppercase; }
216 .doc-head-meta { color: var(--muted); font-size: 13px; margin: 6px 0 0; font-family: ui-monospace, Menlo, monospace; }
217 .doc-layout { display: block; margin-top: 18px; }
218 .doc-layout.has-toc { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 32px; align-items: start; }
219 .doc-layout .doc { margin: 0; max-width: 820px; }
247 +.doc-head-row h1 {
248 + font-family: var(--font-display); font-weight: 620; letter-spacing: -.015em;
249 + font-size: clamp(1.5rem, 4vw, 2.1rem); margin: 0; text-transform: capitalize;
250 +}
251 +.doc-head-meta { font-family: var(--font-mono); font-size: .74rem; color: var(--faint); margin: 6px 0 0; }
252 +.doc-layout { display: block; }
253 +.doc-layout.has-toc { display: grid; grid-template-columns: 230px 1fr; gap: 24px; align-items: start; }
254 +@media (max-width: 1000px) { .doc-layout.has-toc { display: block; } .toc { display: none; } }
220 255 .toc {
221 position: sticky; top: 76px; background: var(--surface); border: 1px solid var(--grid);
222 border-radius: 10px; padding: 14px 16px; max-height: calc(100vh - 100px); overflow-y: auto;
256 + position: sticky; top: 76px;
257 + border: 1px solid var(--hairline); border-radius: var(--radius);
258 + background: var(--card); padding: 14px 16px; font-size: .82rem;
259 + max-height: calc(100vh - 100px); overflow-y: auto;
223 260 }
224 .toc-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); font-weight: 600; }
261 +.toc-title { font-weight: 700; font-size: .72rem; letter-spacing: .1em; text-transform: uppercase; color: var(--faint); }
225 262 .toc ul { list-style: none; margin: 8px 0 0; padding: 0; }
226 .toc li { padding: 3px 0; line-height: 1.35; }
227 .toc li a { color: var(--ink-2); font-size: 13px; display: block; }
228 .toc li a:hover { color: var(--accent-dark); text-decoration: none; }
229 .toc-d3 { padding-left: 14px !important; }
230 .toc-d3 a { font-size: 12.5px !important; color: var(--muted) !important; }
231 .hanchor { opacity: 0; margin-left: 8px; font-weight: 400; color: var(--muted); font-size: 0.85em; }
232 h2:hover .hanchor, h3:hover .hanchor, h4:hover .hanchor { opacity: 1; text-decoration: none; }
233 html { scroll-behavior: smooth; scroll-padding-top: 72px; }
234 .card-head-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
263 +.toc li { margin: 5px 0; }
264 +.toc-d3 { padding-left: 14px; }
265 +.toc a { color: var(--muted); }
266 +.doc { min-width: 0; }
235 267
236 /* ---- research-log timeline ----------------------------------------------- */
237 .timeline { margin: 24px 0; max-width: 860px; }
238 .tl-entry { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 14px; }
268 +.md { font-size: .96rem; }
269 +.md h1, .md h2, .md h3 {
270 + font-family: var(--font-display); font-weight: 620; letter-spacing: -.01em;
271 + margin: 1.6em 0 .5em; line-height: 1.2;
272 +}
273 +.md h1 { font-size: 1.7rem; } .md h2 { font-size: 1.35rem; } .md h3 { font-size: 1.1rem; }
274 +.md p { margin: .7em 0; }
275 +.md li { margin: .3em 0; }
276 +.md code {
277 + font-family: var(--font-mono); font-size: .82em;
278 + background: var(--code-bg); border: 1px solid var(--hairline);
279 + border-radius: 6px; padding: .1em .4em;
280 + overflow-wrap: break-word;
281 +}
282 +.md pre code, .codeblock code { background: none; border: 0; padding: 0; }
283 +.md blockquote {
284 + margin: 1em 0; padding: 2px 18px; border-left: 3px solid var(--accent);
285 + background: var(--accent-soft); border-radius: 0 10px 10px 0; color: var(--accent-ink);
286 +}
287 +.md table { border-collapse: collapse; display: block; overflow-x: auto; max-width: 100%; margin: 1em 0; }
288 +.md th, .md td { border: 1px solid var(--hairline-2); padding: 7px 12px; font-size: .86rem; text-align: left; white-space: nowrap; }
289 +.md th { background: var(--code-bg); font-weight: 640; }
290 +.md hr { border: 0; border-top: 1px solid var(--hairline); margin: 2em 0; }
291 +.md-compact { font-size: .87rem; }
292 +.md-compact p { margin: .4em 0; }
293 +.hanchor { opacity: 0; margin-left: 8px; font-size: .8em; color: var(--faint); }
294 +.md h1:hover .hanchor, .md h2:hover .hanchor, .md h3:hover .hanchor { opacity: 1; }
295 +
296 +.codeblock {
297 + background: var(--code-bg); border: 1px solid var(--hairline);
298 + border-radius: 12px; padding: 16px 18px;
299 + overflow-x: auto; -webkit-overflow-scrolling: touch;
300 + font-family: var(--font-mono); font-size: .8rem; line-height: 1.55;
301 + max-width: 100%;
302 +}
303 +.note-line { color: var(--muted); font-size: .9rem; margin: 26px 0 40px; }
304 +
305 +/* highlight.js — quiet light theme */
306 +.hljs { color: var(--ink); }
307 +.hljs-comment, .hljs-quote { color: #8a8f7c; font-style: italic; }
308 +.hljs-keyword, .hljs-selector-tag, .hljs-meta { color: #9d3b8f; }
309 +.hljs-string, .hljs-attr, .hljs-regexp { color: #1e7a4d; }
310 +.hljs-number, .hljs-literal { color: #b25710; }
311 +.hljs-title, .hljs-name, .hljs-section { color: var(--accent-ink); font-weight: 600; }
312 +.hljs-built_in, .hljs-type { color: #0f6f74; }
313 +.hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: 700; }
314 +
315 +/* --------------------------------------------------------------- log/time */
316 +.log-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
317 +.log-entry { min-width: 0; }
318 +.log-entry h3 { font-size: 1rem; margin: 4px 0 6px; font-weight: 640; }
319 +.tl-time { font-family: var(--font-mono); font-size: .72rem; color: var(--faint); letter-spacing: .05em; }
320 +.timeline { display: flex; flex-direction: column; margin: 22px 0 40px; }
321 +.tl-entry { display: grid; grid-template-columns: 26px 1fr; gap: 14px; }
239 322 .tl-rail { position: relative; }
240 323 .tl-rail::before {
241 content: ""; position: absolute; left: 11px; top: 4px; bottom: -18px; width: 2px; background: var(--grid);
324 + content: ""; position: absolute; left: 12px; top: 4px; bottom: -4px;
325 + width: 2px; background: var(--hairline-2);
242 326 }
243 .tl-entry:last-child .tl-rail::before { bottom: auto; height: 24px; }
327 +.tl-entry:last-child .tl-rail::before { bottom: auto; height: 30px; }
244 328 .tl-dot {
245 position: absolute; left: 5px; top: 6px; width: 14px; height: 14px; border-radius: 50%;
246 background: var(--accent); border: 3px solid var(--page); box-shadow: 0 0 0 1px var(--grid);
329 + position: absolute; left: 7px; top: 8px; width: 12px; height: 12px;
330 + border-radius: 50%; background: var(--accent); border: 3px solid var(--paper);
247 331 }
248 332 .tl-card {
249 background: var(--surface); border: 1px solid var(--grid); border-radius: 10px;
250 padding: 16px 20px; margin-bottom: 18px; min-width: 0;
251 }
252 .tl-time { font-size: 12px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; }
253 .tl-card h3 { margin: 4px 0 10px; font-size: 16.5px; letter-spacing: -0.01em; }
254 .md-compact { font-size: 14px; color: var(--ink-2); }
255 .md-compact ul { padding-left: 18px; margin: 6px 0; }
256 .md-compact li { margin: 3px 0; }
257 .md-compact p { margin: 6px 0; }
258 .md-compact strong { color: var(--ink); }
259 .md-compact pre { font-size: 12px; padding: 10px 12px; }
333 + background: var(--card); border: 1px solid var(--hairline);
334 + border-radius: var(--radius); box-shadow: var(--shadow);
335 + padding: 18px 20px; margin: 0 0 18px; min-width: 0;
336 +}
337 +.tl-card h3 { margin: 4px 0 8px; font-size: 1.05rem; font-weight: 660; }
338 +@media (max-width: 640px) {
339 + .tl-entry { grid-template-columns: 1fr; }
340 + .tl-rail { display: none; }
341 +}
260 342
261 /* ---- smartphone adaptation ------------------------------------------------ */
262 @media (max-width: 720px) {
263 body { font-size: 15px; }
264 .wrap { padding: 0 16px; }
265 .header-row { height: auto; flex-direction: column; align-items: flex-start; gap: 2px; padding: 10px 0; }
266 .site-nav { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; padding-bottom: 4px; }
267 .site-nav a { white-space: nowrap; padding: 6px 10px; font-size: 13.5px; }
268 .site-header { position: static; }
269 html { scroll-padding-top: 12px; }
270 .hero { padding: 28px 0 4px; }
271 .hero h1 { font-size: 26px; }
272 .lede { font-size: 16px; }
273 .lede-eq code { font-size: 11px; white-space: normal; word-break: break-word; }
274 .tiles { grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 20px 0; }
275 .tile { padding: 14px 16px; }
276 .tile-value { font-size: 26px; }
277 .card { padding: 16px 16px; }
278 .page-title { font-size: 24px; margin-top: 24px; }
279 .doc-head h1 { font-size: 22px; }
280 .doc-layout.has-toc { display: block; }
281 .toc { position: static; max-height: none; margin-bottom: 16px; }
282 .tl-entry { grid-template-columns: 18px minmax(0, 1fr); gap: 10px; }
283 .tl-card { padding: 12px 14px; }
284 .md h1 { font-size: 22px; } .md h2 { font-size: 18px; } .md h3 { font-size: 16px; }
285 .md pre, .codeblock { font-size: 12px; padding: 10px 12px; }
286 .results-table, .kv-table { font-size: 13px; display: block; overflow-x: auto; }
287 .kv-table th { width: auto; }
288 .file-head { flex-direction: column; gap: 2px; }
289 .site-footer .wrap { flex-direction: column; }
290 .chart-fig svg { min-width: 560px; }
291 .chart-fig { overflow-x: auto; -webkit-overflow-scrolling: touch; }
343 +/* ---------------------------------------------------------------- tables */
344 +.results-table, .kv-table {
345 + width: 100%; border-collapse: collapse; margin: 18px 0 40px;
346 + background: var(--card); border: 1px solid var(--hairline);
347 + border-radius: var(--radius); box-shadow: var(--shadow);
348 + overflow: hidden; font-size: .88rem;
349 + display: block; overflow-x: auto; -webkit-overflow-scrolling: touch;
292 350 }
293 @media (max-width: 420px) {
294 .tiles { grid-template-columns: 1fr 1fr; }
295 .hero h1 { font-size: 23px; }
351 +.results-table thead tr { background: var(--code-bg); }
352 +.results-table th, .kv-table th {
353 + text-align: left; font-weight: 640; padding: 10px 16px; white-space: nowrap;
296 354 }
355 +.results-table td, .kv-table td { padding: 9px 16px; border-top: 1px solid var(--hairline); }
356 +.kv-table th { width: 180px; vertical-align: top; color: var(--muted); }
357 +.mono-small { font-family: var(--font-mono); font-size: .74rem; color: var(--faint); }
297 358
298 /* ---- anomaly-atlas additions: honesty banner + confidence-level badges ---- */
299 .honesty-banner {
300 background: #fdf6ec;
301 border-bottom: 1px solid #eadfc8;
302 color: #5a4a2a;
303 font-size: 0.82rem;
304 line-height: 1.45;
305 padding: 8px 0;
306 }
307 .honesty-banner strong { color: #4a3a1e; }
308 .badge-level-0 { background: #f0efeb; color: #6b6b66; }
309 .badge-level-1 { background: #e7f0fb; color: #2a5da8; }
310 .badge-level-2 { background: #eaf6ef; color: #1e7a4d; }
311 .badge-level-3 { background: #163a26; color: #eafff3; }
359 +.file-list { list-style: none; padding: 0; margin: 0; }
360 +.file-list li { padding: 7px 0; border-bottom: 1px dashed var(--hairline); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
361 +.file-list li:last-child { border-bottom: 0; }
362 +
363 +.tree { list-style: none; padding: 0; margin: 18px 0 40px; }
364 +.tree-root { margin: 14px 0; background: var(--card); border: 1px solid var(--hairline); border-radius: var(--radius); padding: 14px 18px; }
365 +.tree-root > strong { font-family: var(--font-mono); font-size: .85rem; }
366 +.tree-root ul { list-style: none; padding: 6px 0 0 10px; margin: 0; columns: 2; column-gap: 30px; }
367 +.tree-root li { font-family: var(--font-mono); font-size: .8rem; padding: 2px 0; break-inside: avoid; }
368 +@media (max-width: 700px) { .tree-root ul { columns: 1; } }
369 +
370 +.file-card { padding: 0; overflow: hidden; }
371 +.file-head {
372 + display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
373 + padding: 10px 18px; border-bottom: 1px solid var(--hairline); background: var(--code-bg);
374 +}
375 +.file-card .codeblock { border: 0; border-radius: 0; margin: 0; }
376 +
377 +.chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 12px; }
378 +.chip {
379 + font-family: var(--font-mono); font-size: .68rem; color: var(--muted);
380 + background: var(--code-bg); border: 1px solid var(--hairline);
381 + border-radius: 999px; padding: 2px 10px;
382 +}
383 +.chip-k { color: var(--faint); margin-right: 5px; text-transform: uppercase; letter-spacing: .06em; }
384 +
385 +/* -------------------------------------------------------------- comments */
386 +.comment-form {
387 + display: grid; gap: 12px; margin: 6px 0 10px;
388 +}
389 +.comment-form label { font-size: .82rem; font-weight: 640; color: var(--muted); display: block; margin-bottom: 4px; }
390 +.comment-form input[type="text"], .comment-form textarea {
391 + width: 100%; font-family: var(--font-text); font-size: .95rem; color: var(--ink);
392 + background: var(--paper); border: 1px solid var(--hairline-2);
393 + border-radius: 10px; padding: 10px 14px;
394 +}
395 +.comment-form input:focus, .comment-form textarea:focus {
396 + outline: 2px solid var(--accent-soft); border-color: var(--accent);
397 +}
398 +.comment-form textarea { min-height: 120px; resize: vertical; }
399 +.comment-form .hp { position: absolute; left: -9999px; opacity: 0; height: 0; overflow: hidden; }
400 +.btn {
401 + display: inline-block; cursor: pointer; border: 0;
402 + background: var(--ink); color: var(--paper);
403 + font-family: var(--font-text); font-weight: 640; font-size: .95rem;
404 + padding: 11px 22px; border-radius: 999px; justify-self: start;
405 +}
406 +.btn:hover { background: var(--accent-ink); }
407 +.form-note { font-size: .78rem; color: var(--faint); margin: 0; }
408 +.form-error {
409 + background: #fbecec; border: 1px solid #eac3c3; color: #8a2a2a;
410 + border-radius: 10px; padding: 10px 14px; font-size: .88rem;
411 +}
412 +.form-ok {
413 + background: var(--ok-soft); border: 1px solid #bfe3cd; color: var(--ok);
414 + border-radius: 10px; padding: 10px 14px; font-size: .88rem;
415 +}
416 +.comment-list { display: flex; flex-direction: column; gap: 12px; margin: 18px 0 40px; }
417 +.comment {
418 + background: var(--card); border: 1px solid var(--hairline);
419 + border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px 20px;
420 +}
421 +.comment-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 6px; }
422 +.comment-head strong { font-size: .95rem; }
423 +.comment-body { font-size: .92rem; color: var(--ink); white-space: pre-wrap; overflow-wrap: break-word; }
424 +.comment-page { font-family: var(--font-mono); font-size: .7rem; color: var(--faint); }
425 +
426 +/* ---------------------------------------------------------------- footer */
427 +.site-footer {
428 + border-top: 1px solid var(--hairline); background: var(--card);
429 + margin-top: 48px; padding: 20px 0; font-size: .8rem; color: var(--muted);
430 +}
431 +.site-footer .wrap { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
432 +.footer-meta { font-family: var(--font-mono); font-size: .72rem; color: var(--faint); }
433 +
434 +@media (max-width: 640px) {
435 + body { font-size: 15px; }
436 + .hero { padding-top: 34px; }
437 + .card { padding: 18px; }
438 + .tiles { grid-template-columns: repeat(2, 1fr); }
439 + .site-footer .wrap { flex-direction: column; }
440 +}
modified web/server.js +89 −0
@@ -12,6 +12,8 @@
12 12 // ============================================================================
13 13 "use strict";
14 14
15 +const fs = require("fs");
16 +const os = require("os");
15 17 const path = require("path");
16 18 const express = require("express");
17 19 const C = require("./lib/content");
@@ -20,7 +22,13 @@ const R = require("./lib/render");
20 22 const app = express();
21 23 const PORT = process.env.PORT || 8150;
22 24
25 +// Comments live OUTSIDE the deploy directory so rsync --delete redeploys
26 +// never erase them.
27 +const COMMENTS_PATH =
28 + process.env.COMMENTS_PATH || path.join(os.homedir(), ".anomaly-atlas", "comments.json");
29 +
23 30 app.use("/static", express.static(path.join(__dirname, "public"), { maxAge: "1h" }));
31 +app.use(express.urlencoded({ extended: false, limit: "16kb" }));
24 32
25 33 function page(res, opts) {
26 34 res.send(R.layout({ ...opts, buildInfo: C.buildInfo() }));
@@ -381,6 +389,87 @@ no hand-typed numbers.</p>
381 389 page(res, { title: "About", active: "About", body });
382 390 });
383 391
392 +// ---------------------------------------------------------------- comments
393 +function loadComments() {
394 + try {
395 + return JSON.parse(fs.readFileSync(COMMENTS_PATH, "utf8"));
396 + } catch {
397 + return [];
398 + }
399 +}
400 +
401 +function saveComment(entry) {
402 + fs.mkdirSync(path.dirname(COMMENTS_PATH), { recursive: true });
403 + const all = loadComments();
404 + all.push(entry);
405 + fs.writeFileSync(COMMENTS_PATH, JSON.stringify(all, null, 2));
406 +}
407 +
408 +const lastPostByIp = new Map(); // ip -> epoch ms (simple anti-flood)
409 +
410 +function commentsBody(flash) {
411 + const comments = loadComments().slice().reverse();
412 + const list = comments
413 + .map(
414 + (c) => `<article class="comment">
415 + <div class="comment-head"><strong>${R.esc(c.name)}</strong>
416 + <time class="tl-time">${R.esc((c.ts || "").slice(0, 16).replace("T", " "))} UTC</time>
417 + ${c.page ? `<span class="comment-page">on ${R.esc(c.page)}</span>` : ""}</div>
418 + <div class="comment-body">${R.esc(c.message)}</div></article>`
419 + )
420 + .join("");
421 + return `<h1 class="page-title">Comments</h1>
422 +<p class="lede-small">Questions, methodological objections, artifact reports, pointers to literature —
423 +all welcome. The most valuable comment here is "your effect is explained by X". Comments are public.
424 +Nothing on this page (or this site) is investment advice.</p>
425 +<section class="card">
426 + <h2>Leave a comment</h2>
427 + ${flash || ""}
428 + <form class="comment-form" method="POST" action="/comments">
429 + <div><label for="c-name">Name</label>
430 + <input type="text" id="c-name" name="name" maxlength="60" required placeholder="Your name or handle"></div>
431 + <div><label for="c-msg">Comment</label>
432 + <textarea id="c-msg" name="message" maxlength="2000" required placeholder="Be specific — cite the experiment or finding if possible."></textarea></div>
433 + <div><label for="c-page">Page it concerns (optional)</label>
434 + <input type="text" id="c-page" name="page" maxlength="120" placeholder="/experiments/expB_artifact_baselines"></div>
435 + <div class="hp"><label for="c-web">Website</label>
436 + <input type="text" id="c-web" name="website" tabindex="-1" autocomplete="off"></div>
437 + <button class="btn" type="submit">Post comment</button>
438 + <p class="form-note">Plain text only. One comment per 30 seconds. Abusive or promotional content is removed.</p>
439 + </form>
440 +</section>
441 +<div class="comment-list">${list || '<p class="lede-small">No comments yet — be the first.</p>'}</div>`;
442 +}
443 +
444 +app.get("/comments", (req, res) => {
445 + let flash = "";
446 + if (req.query.ok) flash = `<p class="form-ok">Comment posted — thank you.</p>`;
447 + else if (req.query.err) flash = `<p class="form-error">${R.esc(String(req.query.err))}</p>`;
448 + page(res, { title: "Comments", active: "Comments", body: commentsBody(flash) });
449 +});
450 +
451 +app.post("/comments", (req, res) => {
452 + const ip = req.headers["x-forwarded-for"] || req.socket.remoteAddress || "?";
453 + const now = Date.now();
454 + const err = (msg) => res.redirect(303, "/comments?err=" + encodeURIComponent(msg));
455 + if (now - (lastPostByIp.get(ip) || 0) < 30_000) return err("Please wait 30 seconds between comments.");
456 + const { name = "", message = "", page: pageRef = "", website = "" } = req.body || {};
457 + if (website.trim() !== "") return err("Submission rejected.");
458 + const cleanName = String(name).trim().slice(0, 60);
459 + const cleanMsg = String(message).trim().slice(0, 2000);
460 + const cleanPage = String(pageRef).trim().slice(0, 120);
461 + if (cleanName.length < 1) return err("Name is required.");
462 + if (cleanMsg.length < 3) return err("Comment is too short.");
463 + lastPostByIp.set(ip, now);
464 + saveComment({
465 + name: cleanName,
466 + message: cleanMsg,
467 + page: cleanPage || null,
468 + ts: new Date().toISOString(),
469 + });
470 + res.redirect(303, "/comments?ok=1");
471 +});
472 +
384 473 // ---------------------------------------------------------------- misc
385 474 app.get("/health", (req, res) => res.json({ ok: true, app: "anomaly-atlas-web", author: "Simon-Pierre Boucher", data_source: "hfmarketdata.io" }));
386 475
387 476