SPB Git

spb/localvm-research Public License

Running LLMs larger than memory on a consumer Mac — falsification-driven research: margin-gated deferred refinement, out-of-core verification on Apple Silicon. TR-01 published.

Python 63.2% JavaScript 23.5% CSS 11.8% Shell 0.9% Makefile 0.5%

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>
Simon-Pierre Boucher committed 3 h ago (Aug 12, 2026) parent aeb970c

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 &amp; 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