Web: comments page redesign (avatars, clean form), cache-busted assets, fix orphan /results links
- 404 fix: scale-run results (candidate_01_scale32b) now attach to the candidate_01 experiment page; results table links only to real pages - Comments: centered single column, header band form, uppercase field labels, focus rings, avatar initials, empty state - style.css/app.js served with ?v=<commit> (stale-cache mix caused the broken look after deploys) - Home phases list: Phase 7 in progress, Phase 11 done Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 4 changed files with +102 and −43
modified
web/lib/content.js
+1 −1
@@ -105,7 +105,7 @@ function listExperiments() { | ||
| 105 | 105 | if (!e.dir || e.name === "micro") continue; |
| 106 | 106 | const readme = readMarkdown(path.posix.join(e.rel, "README.md")); |
| 107 | 107 | const hasAnalysis = (readText(path.posix.join(e.rel, "analysis.md")) || "").length > 400; |
| 108 | − const runs = listResultRuns().filter((r) => r.experiment === e.name); | |
| 108 | + const runs = listResultRuns().filter((r) => r.experiment === e.name || r.experiment.startsWith(e.name + "_")); | |
| 109 | 109 | let purpose = ""; |
| 110 | 110 | if (readme) { |
| 111 | 111 | const line = readme.content.split("\n").find((l) => l.trim() && !l.startsWith("#")); |
modified
web/lib/render.js
+2 −2
@@ -150,7 +150,7 @@ function layout({ title, active, body, buildInfo = {} }) { | ||
| 150 | 150 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 151 | 151 | <title>${esc(title)} · localvm-research</title> |
| 152 | 152 | <meta name="description" content="localvm-research — running LLMs larger than memory on consumer Apple Silicon. Research by Simon-Pierre Boucher."> |
| 153 | −<link rel="stylesheet" href="/static/style.css"> | |
| 153 | +<link rel="stylesheet" href="/static/style.css?v=${esc(commit)}"> | |
| 154 | 154 | <link rel="icon" type="image/svg+xml" href="/static/logo.svg"> |
| 155 | 155 | <link rel="apple-touch-icon" href="/static/logo.svg"> |
| 156 | 156 | </head> |
@@ -187,7 +187,7 @@ function layout({ title, active, body, buildInfo = {} }) { | ||
| 187 | 187 | </div> |
| 188 | 188 | <div class="wrap footer-legal">© 2026 Simon-Pierre Boucher · All rights reserved (research code) · Apple M5 Max / 48 GB / macOS 27</div> |
| 189 | 189 | </footer> |
| 190 | −<script src="/static/app.js" defer></script> | |
| 190 | +<script src="/static/app.js?v=${esc(commit)}" defer></script> | |
| 191 | 191 | </body> |
| 192 | 192 | </html>`; |
| 193 | 193 | } |
modified
web/public/style.css
+43 −15
@@ -270,29 +270,58 @@ h2:hover .hanchor, h3:hover .hanchor, h4:hover .hanchor { opacity: 1; text-decor | ||
| 270 | 270 | } |
| 271 | 271 | |
| 272 | 272 | /* ---------------------------------------------------------------- comments */ |
| 273 | −.comment-grid { display: grid; grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); gap: 24px; margin: 24px 0; align-items: start; } | |
| 274 | −.comment-form label { display: block; font-size: 13.5px; font-weight: 650; color: var(--ink-2); margin: 14px 0 4px; } | |
| 273 | +.comments-wrap { max-width: 720px; margin: 0 auto; } | |
| 274 | +.comment-form { | |
| 275 | + background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius); | |
| 276 | + box-shadow: var(--shadow-1); margin: 26px 0 40px; overflow: hidden; | |
| 277 | +} | |
| 278 | +.cf-head { | |
| 279 | + padding: 14px 22px; font-weight: 700; font-size: 15px; | |
| 280 | + background: linear-gradient(180deg, var(--accent-wash), var(--surface)); | |
| 281 | + border-bottom: 1px solid var(--grid); | |
| 282 | +} | |
| 283 | +.cf-body { padding: 20px 22px 22px; } | |
| 284 | +.field { display: block; margin-bottom: 16px; } | |
| 285 | +.field-label { | |
| 286 | + display: block; font-size: 12.5px; font-weight: 700; color: var(--ink-2); | |
| 287 | + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; | |
| 288 | +} | |
| 275 | 289 | .comment-form input, .comment-form textarea { |
| 276 | − width: 100%; border: 1px solid var(--grid); border-radius: 9px; background: var(--page); | |
| 277 | − padding: 10px 12px; font-size: 14.5px; font-family: inherit; color: var(--ink); | |
| 278 | − transition: border-color 0.15s, box-shadow 0.15s; | |
| 290 | + width: 100%; border: 1.5px solid var(--grid); border-radius: 10px; background: var(--page); | |
| 291 | + padding: 11px 14px; font-size: 15px; font-family: inherit; color: var(--ink); | |
| 292 | + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; | |
| 293 | + resize: vertical; | |
| 279 | 294 | } |
| 295 | +.comment-form input::placeholder, .comment-form textarea::placeholder { color: var(--baseline); } | |
| 280 | 296 | .comment-form input:focus, .comment-form textarea:focus { |
| 281 | − outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(42,120,214,0.15); | |
| 297 | + outline: none; border-color: var(--accent); background: var(--surface); | |
| 298 | + box-shadow: 0 0 0 4px rgba(42,120,214,0.12); | |
| 282 | 299 | } |
| 283 | −.comment-form .btn { margin-top: 16px; } | |
| 284 | −.comment-form .hp { position: absolute; left: -9999px; height: 0; width: 0; border: 0; padding: 0; } | |
| 285 | −.comment { | |
| 286 | − background: var(--surface); border: 1px solid var(--grid); border-radius: var(--radius); | |
| 287 | − padding: 16px 20px; margin-bottom: 14px; box-shadow: var(--shadow-1); | |
| 300 | +.cf-foot { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; margin-top: 4px; } | |
| 301 | +.cf-hint { font-size: 12.5px; color: var(--muted); } | |
| 302 | +.hp { position: absolute !important; left: -9999px !important; height: 0; width: 0; border: 0; padding: 0; } | |
| 303 | +.cl-head { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 2px solid var(--ink); padding-bottom: 8px; margin-bottom: 20px; } | |
| 304 | +.cl-head h2 { margin: 0; font-size: 18px; font-weight: 750; } | |
| 305 | +.comment { display: flex; gap: 14px; padding: 18px 0; border-bottom: 1px solid var(--grid); } | |
| 306 | +.comment:last-child { border-bottom: 0; } | |
| 307 | +.avatar { | |
| 308 | + flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%; | |
| 309 | + display: flex; align-items: center; justify-content: center; | |
| 310 | + color: #fff; font-weight: 750; font-size: 17px; | |
| 288 | 311 | } |
| 312 | +.comment-main { min-width: 0; flex: 1; } | |
| 289 | 313 | .comment-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; flex-wrap: wrap; } |
| 290 | −.comment-name { font-weight: 700; font-size: 14.5px; } | |
| 314 | +.comment-name { font-weight: 720; font-size: 15px; } | |
| 291 | 315 | .comment-time { font-size: 12px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; } |
| 292 | −.comment-body { margin: 8px 0 0; font-size: 14.5px; color: var(--ink-2); white-space: pre-wrap; } | |
| 316 | +.comment-body { margin: 5px 0 0; font-size: 15px; color: var(--ink-2); white-space: pre-wrap; line-height: 1.6; } | |
| 317 | +.empty-state { | |
| 318 | + text-align: center; padding: 44px 20px; color: var(--muted); | |
| 319 | + background: var(--surface); border: 1px dashed var(--baseline); border-radius: var(--radius); | |
| 320 | +} | |
| 321 | +.empty-icon { font-size: 30px; display: block; margin-bottom: 8px; } | |
| 293 | 322 | .flash { |
| 294 | 323 | background: #eef7ee; border: 1px solid #cfe8cf; color: var(--good-text); |
| 295 | − border-radius: 9px; padding: 10px 16px; margin: 14px 0; font-size: 14px; font-weight: 600; | |
| 324 | + border-radius: 10px; padding: 11px 18px; margin: 16px 0; font-size: 14px; font-weight: 600; | |
| 296 | 325 | } |
| 297 | 326 | .flash-err { background: #fdf0ef; border-color: #f2cfcc; color: #a13430; } |
| 298 | 327 | |
@@ -310,7 +339,6 @@ h2:hover .hanchor, h3:hover .hanchor, h4:hover .hanchor { opacity: 1; text-decor | ||
| 310 | 339 | @media (max-width: 880px) { |
| 311 | 340 | .split { grid-template-columns: 1fr; } |
| 312 | 341 | .hero h1 { font-size: 32px; } |
| 313 | − .comment-grid { grid-template-columns: 1fr; } | |
| 314 | 342 | } |
| 315 | 343 | @media (max-width: 720px) { |
| 316 | 344 | body { font-size: 15px; } |
modified
web/server.js
+56 −25
@@ -67,7 +67,8 @@ app.get("/", (req, res) => { | ||
| 67 | 67 | ["Phase 3 — Research gaps", s.gaps ? "done" : "pending", `${s.gaps} falsifiable approaches (G01–G${String(s.gaps).padStart(2, "0")})`], |
| 68 | 68 | ["Phase 4 — Candidate ranking", C.exists("research/candidate_ranking.md") ? "done" : "in progress", "10-axis scoring, 3–5 candidates"], |
| 69 | 69 | ["Phases 5–6 — Framework & micro-experiments", s.experimentsDone ? "in progress" : "pending", `${s.experimentsDone}/${s.experiments} experiments completed`], |
| 70 | − ["Phases 7–11 — Prototypes → novelty check", "pending", "evidence-driven"], | |
| 70 | + ["Phase 7 — Candidate prototype", C.exists("experiments/candidate_01/analysis.md") ? "in progress" : "pending", "margin-gated deferred refinement, 1.7B + 32B runs"], | |
| 71 | + ["Phase 11 — Novelty check", C.exists("research/novelty_check.md") ? "done" : "pending", "adversarial prior-art search, 45 sources"], | |
| 71 | 72 | ]; |
| 72 | 73 | const phaseHtml = phases |
| 73 | 74 | .map( |
@@ -223,7 +224,7 @@ app.get("/experiments/:id", (req, res) => { | ||
| 223 | 224 | <div class="md">${R.markdownToHtml(md.content, path.posix.join(exp.rel, file)).html}</div></section>`); |
| 224 | 225 | } |
| 225 | 226 | } |
| 226 | − const runs = C.listResultRuns().filter((r) => r.experiment === exp.id); | |
| 227 | + const runs = C.listResultRuns().filter((r) => r.experiment === exp.id || r.experiment.startsWith(exp.id + "_")); | |
| 227 | 228 | const runsHtml = runs.length |
| 228 | 229 | ? `<section class="card"><h2>Result runs</h2><ul class="file-list">` + |
| 229 | 230 | runs.map((r) => r.files.map((f) => `<li><a href="/results/${f.rel}">${r.timestamp} / ${f.name}</a> <span class="mono-small">${(f.size / 1024).toFixed(1)} KiB</span></li>`).join("")).join("") + |
@@ -241,11 +242,18 @@ ${codeLink}${sections.join("")}${runsHtml}`; | ||
| 241 | 242 | // ---------------------------------------------------------------- results |
| 242 | 243 | app.get("/results", (req, res) => { |
| 243 | 244 | const runs = C.listResultRuns(); |
| 245 | + const expIds = C.listExperiments().map((e) => e.id); | |
| 244 | 246 | const rows = runs |
| 245 | 247 | .map( |
| 246 | − (r) => `<tr><td><a href="/experiments/${r.experiment}">${r.experiment}</a></td> | |
| 248 | + (r) => { | |
| 249 | + const target = expIds.find((id) => r.experiment === id || r.experiment.startsWith(id + "_")); | |
| 250 | + const expCell = target | |
| 251 | + ? `<a href="/experiments/${target}">${r.experiment}</a>` | |
| 252 | + : r.experiment; | |
| 253 | + return `<tr><td>${expCell}</td> | |
| 247 | 254 | <td class="mono-small">${r.timestamp}</td> |
| 248 | − <td>${r.files.map((f) => `<a href="/results/${f.rel}">${f.name}</a>`).join(" · ")}</td></tr>` | |
| 255 | + <td>${r.files.map((f) => `<a href="/results/${f.rel}">${f.name}</a>`).join(" · ")}</td></tr>`; | |
| 256 | + } | |
| 249 | 257 | ) |
| 250 | 258 | .join(""); |
| 251 | 259 | const body = `<h1 class="page-title">Raw results</h1> |
@@ -346,34 +354,57 @@ why an approach died, not just what survived.</p> | ||
| 346 | 354 | |
| 347 | 355 | // ---------------------------------------------------------------- comments |
| 348 | 356 | app.get("/comments", (req, res) => { |
| 357 | + const AVATAR_COLORS = ["#2a78d6", "#eb6834", "#1baf7a", "#4a3aa7", "#e87ba4", "#008300"]; | |
| 349 | 358 | const comments = readComments().slice().reverse(); |
| 350 | 359 | const list = comments.length |
| 351 | − ? comments.map((c) => `<article class="comment"> | |
| 352 | − <div class="comment-head"><span class="comment-name">${R.esc(c.name)}</span> | |
| 353 | − <time class="comment-time">${R.esc((c.ts || "").slice(0, 16).replace("T", " "))} UTC</time></div> | |
| 354 | − <p class="comment-body">${R.esc(c.message)}</p> | |
| 355 | − </article>`).join("") | |
| 356 | − : `<p class="lede-small">No comments yet — be the first to leave one.</p>`; | |
| 360 | + ? comments.map((c) => { | |
| 361 | + const initial = (c.name || "?").trim()[0].toUpperCase(); | |
| 362 | + const hue = AVATAR_COLORS[[...String(c.name)].reduce((a, ch) => a + ch.charCodeAt(0), 0) % AVATAR_COLORS.length]; | |
| 363 | + return `<article class="comment"> | |
| 364 | + <span class="avatar" style="background:${hue}">${R.esc(initial)}</span> | |
| 365 | + <div class="comment-main"> | |
| 366 | + <div class="comment-head"><span class="comment-name">${R.esc(c.name)}</span> | |
| 367 | + <time class="comment-time">${R.esc((c.ts || "").slice(0, 16).replace("T", " "))} UTC</time></div> | |
| 368 | + <p class="comment-body">${R.esc(c.message)}</p> | |
| 369 | + </div> | |
| 370 | + </article>`; | |
| 371 | + }).join("") | |
| 372 | + : `<div class="empty-state"> | |
| 373 | + <span class="empty-icon">💬</span> | |
| 374 | + <p>No comments yet — be the first to leave one.</p> | |
| 375 | + </div>`; | |
| 357 | 376 | const posted = req.query.posted ? `<div class="flash">Thanks — your comment is posted.</div>` : ""; |
| 358 | 377 | const err = req.query.err ? `<div class="flash flash-err">${R.esc(String(req.query.err))}</div>` : ""; |
| 359 | − const body = `<h1 class="page-title">Comments & discussion</h1> | |
| 360 | −<p class="lede-small">Questions, critiques, pointers to prior art, replication reports — all welcome. | |
| 361 | −Comments are public and moderated after the fact; be civil, no links required.</p> | |
| 378 | + const body = `<div class="comments-wrap"> | |
| 379 | +<h1 class="page-title">Discussion</h1> | |
| 380 | +<p class="lede-small">Questions, critiques, pointers to prior art we missed, replication reports — | |
| 381 | +all welcome. Comments are public; no account, no tracking.</p> | |
| 362 | 382 | ${posted}${err} |
| 363 | −<div class="comment-grid"> | |
| 364 | − <form class="card comment-form" method="POST" action="/comments"> | |
| 365 | − <h2>Leave a comment</h2> | |
| 366 | − <label>Name<input name="name" maxlength="60" required placeholder="Your name"></label> | |
| 367 | − <label>Comment<textarea name="message" maxlength="2000" rows="5" required | |
| 368 | − placeholder="Your comment — feedback, prior art we missed, questions…"></textarea></label> | |
| 383 | +<form class="comment-form" method="POST" action="/comments"> | |
| 384 | + <div class="cf-head">Leave a comment</div> | |
| 385 | + <div class="cf-body"> | |
| 386 | + <label class="field"> | |
| 387 | + <span class="field-label">Name</span> | |
| 388 | + <input name="name" maxlength="60" required placeholder="Your name" autocomplete="name"> | |
| 389 | + </label> | |
| 390 | + <label class="field"> | |
| 391 | + <span class="field-label">Comment</span> | |
| 392 | + <textarea name="message" maxlength="2000" rows="5" required | |
| 393 | + placeholder="Feedback, prior art, questions…"></textarea> | |
| 394 | + </label> | |
| 369 | 395 | <input type="text" name="website" class="hp" tabindex="-1" autocomplete="off" aria-hidden="true"> |
| 370 | − <button type="submit" class="btn">Post comment</button> | |
| 371 | − <p class="mono-small">Stored on this server only. No tracking, no account.</p> | |
| 372 | − </form> | |
| 373 | − <div class="comment-list"> | |
| 374 | − <h2 class="section-title" style="margin-top:0">${comments.length} comment${comments.length === 1 ? "" : "s"}</h2> | |
| 375 | − ${list} | |
| 396 | + <div class="cf-foot"> | |
| 397 | + <span class="cf-hint">Stored on this server only · moderated after the fact</span> | |
| 398 | + <button type="submit" class="btn">Post comment</button> | |
| 399 | + </div> | |
| 376 | 400 | </div> |
| 401 | +</form> | |
| 402 | +<section class="comment-list"> | |
| 403 | + <div class="cl-head"> | |
| 404 | + <h2>${comments.length} comment${comments.length === 1 ? "" : "s"}</h2> | |
| 405 | + </div> | |
| 406 | + ${list} | |
| 407 | +</section> | |
| 377 | 408 | </div>`; |
| 378 | 409 | page(res, { title: "Comments", active: "Comments", body }); |
| 379 | 410 | }); |
| 380 | 411 | |