Skip to content

Commit f53067e

Browse files
feat(web): self-improvement history timeline on detail pages
Detail pages now show the scene's published visualize -> verify -> refine journey, oldest first: improve runs as horizontal iteration cards (render + score + verdict + critique), verify runs as VERIFIED/FAILED badges with the check count, the self-check source link and an expandable run log. Data comes from /samples/runs/<id>/manifest.json; scenes without published history render nothing. serve: nested /samples/runs/... JSONs (incl. manifest.json) now fall through to the static dist copy — serveSample stays flat-file only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a8135ce commit f53067e

4 files changed

Lines changed: 223 additions & 2 deletions

File tree

lib/serve.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,8 +385,14 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
385385
}
386386

387387
if (pathname.startsWith('/samples/') && pathname.endsWith('.json')) {
388-
const served = await serveSample(res, pathname.slice('/samples/'.length));
389-
if (served) return;
388+
const rel = pathname.slice('/samples/'.length);
389+
// Only FLAT files are workspace-resolved scenes; nested paths (the
390+
// published run history under /samples/runs/<id>/..., incl. its
391+
// manifest.json) fall through to the static dist copy below.
392+
if (!rel.includes('/')) {
393+
const served = await serveSample(res, rel);
394+
if (served) return;
395+
}
390396
}
391397

392398
if (pathname.startsWith('/samples/') && pathname.endsWith('.png')) {

src/components/HistoryTimeline.tsx

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
3+
// The self-improvement history of a published scene, read from the static
4+
// gallery (`/samples/runs/<id>/manifest.json`, written by `visually upload`).
5+
// Renders nothing when a scene ships no history, so every other detail page
6+
// is unaffected. Improve runs show their iteration renders + review verdicts;
7+
// verify runs show the formal-verification outcome with the run log.
8+
9+
type HistoryRun = { dir: string; kind: string; at: string; files: string[] };
10+
type HistoryManifest = { id: string; runs: HistoryRun[] };
11+
12+
type Review = {
13+
total?: number | null;
14+
verdict?: string | null;
15+
critique?: string | null;
16+
changelog?: string[];
17+
remaining_gaps?: string[];
18+
};
19+
20+
type Iteration = { n: string; render: string | null; review: Review | null };
21+
22+
const RENDER_RE = /^iter-(\d+)-render\.png$/;
23+
const VERIFY_TXT_RE = /^verify-(\d+)\.txt$/;
24+
25+
function runBase(id: string, dir: string) {
26+
return `/samples/runs/${encodeURIComponent(id)}/${encodeURIComponent(dir)}`;
27+
}
28+
29+
function fmtDate(at: string) {
30+
return at ? at.slice(0, 10) : '';
31+
}
32+
33+
// ---- improve run: iteration render + review cards --------------------------
34+
35+
function ImproveRun({ id, run }: { id: string; run: HistoryRun }) {
36+
const [reviews, setReviews] = useState<Record<string, Review>>({});
37+
38+
const iterations = useMemo<Iteration[]>(() => {
39+
const ns = new Set<string>();
40+
for (const f of run.files) {
41+
const m = RENDER_RE.exec(f);
42+
if (m) ns.add(m[1]);
43+
}
44+
return [...ns].sort().map((n) => ({
45+
n,
46+
render: run.files.includes(`iter-${n}-render.png`) ? `${runBase(id, run.dir)}/iter-${n}-render.png` : null,
47+
review: reviews[n] ?? null,
48+
}));
49+
}, [id, run, reviews]);
50+
51+
useEffect(() => {
52+
let cancelled = false;
53+
const wanted = run.files.filter((f) => /^iter-\d+-review\.json$/.test(f));
54+
void Promise.all(wanted.map(async (f) => {
55+
try {
56+
const res = await fetch(`${runBase(id, run.dir)}/${f}`);
57+
if (!res.ok) return null;
58+
const data = (await res.json()) as Review;
59+
return { n: /^iter-(\d+)-/.exec(f)?.[1] ?? '', data };
60+
} catch { return null; }
61+
})).then((loaded) => {
62+
if (cancelled) return;
63+
const next: Record<string, Review> = {};
64+
for (const r of loaded) if (r && r.n) next[r.n] = r.data;
65+
setReviews(next);
66+
});
67+
return () => { cancelled = true; };
68+
}, [id, run]);
69+
70+
if (iterations.length === 0) return null;
71+
return (
72+
<div className="htl__strip">
73+
{iterations.map((it) => (
74+
<figure className="htl__card" key={it.n}>
75+
{it.render ? <img className="htl__render" src={it.render} alt={`iteration ${it.n} render`} loading="lazy" /> : null}
76+
<figcaption className="htl__cardbody">
77+
<div className="htl__cardhead">
78+
<span className="htl__iter">iter {Number(it.n)}</span>
79+
{typeof it.review?.total === 'number' ? <span className="htl__score">{it.review.total}/100</span> : null}
80+
{it.review?.verdict ? <span className="htl__verdict">{it.review.verdict}</span> : null}
81+
</div>
82+
{it.review?.critique ? <p className="htl__critique">{it.review.critique}</p> : null}
83+
</figcaption>
84+
</figure>
85+
))}
86+
</div>
87+
);
88+
}
89+
90+
// ---- verify run: formal-verification outcome --------------------------------
91+
92+
function VerifyRun({ id, run }: { id: string; run: HistoryRun }) {
93+
const [log, setLog] = useState<string | null>(null);
94+
const txt = run.files.find((f) => VERIFY_TXT_RE.test(f)) ?? run.files.find((f) => f === 'verify.txt');
95+
96+
useEffect(() => {
97+
if (!txt) return;
98+
let cancelled = false;
99+
fetch(`${runBase(id, run.dir)}/${txt}`)
100+
.then((res) => (res.ok ? res.text() : Promise.reject(res)))
101+
.then((body) => { if (!cancelled) setLog(body); })
102+
.catch(() => { if (!cancelled) setLog(null); });
103+
return () => { cancelled = true; };
104+
}, [id, run, txt]);
105+
106+
const pass = log ? /pass=true|VERIFIED/.test(log) : null;
107+
const checks = log ? (log.match(/^ok {2}/gm) ?? []).length : 0;
108+
const check = run.files.find((f) => /^check-\d+\./.test(f));
109+
return (
110+
<div className="htl__verify">
111+
{pass === null ? null : (
112+
<span className={`htl__badge ${pass ? 'htl__badge--pass' : 'htl__badge--fail'}`}>
113+
{pass ? 'VERIFIED' : 'FAILED'}
114+
</span>
115+
)}
116+
{checks > 0 ? <span className="htl__checks">{checks} checks</span> : null}
117+
{check ? (
118+
<a className="htl__src" href={`${runBase(id, run.dir)}/${check}`} target="_blank" rel="noreferrer">
119+
self-check source
120+
</a>
121+
) : null}
122+
{log ? (
123+
<details className="htl__log">
124+
<summary>run log</summary>
125+
<pre>{log}</pre>
126+
</details>
127+
) : null}
128+
</div>
129+
);
130+
}
131+
132+
// ---- the timeline ------------------------------------------------------------
133+
134+
const KIND_LABEL: Record<string, string> = {
135+
create: 'created',
136+
improve: 'visual self-improve',
137+
verify: 'formal verification',
138+
amend: 'spec amend',
139+
reproduce: 'reproduce',
140+
evidence: 'evidence fetch',
141+
};
142+
143+
export function HistoryTimeline({ id }: { id: string }) {
144+
const [manifest, setManifest] = useState<HistoryManifest | null>(null);
145+
146+
useEffect(() => {
147+
let cancelled = false;
148+
fetch(`/samples/runs/${encodeURIComponent(id)}/manifest.json`)
149+
.then((res) => (res.ok ? (res.json() as Promise<HistoryManifest>) : Promise.reject(res)))
150+
.then((data) => { if (!cancelled && Array.isArray(data.runs) && data.runs.length > 0) setManifest(data); })
151+
.catch(() => { /* no published history — render nothing */ });
152+
return () => { cancelled = true; };
153+
}, [id]);
154+
155+
if (!manifest) return null;
156+
return (
157+
<section className="htl" aria-label="Self-improvement history">
158+
<header className="htl__head">
159+
<h2 className="htl__title">Self-improvement history</h2>
160+
<p className="htl__sub">
161+
every step of the visualize → verify → refine loop that produced this scene, oldest first
162+
</p>
163+
</header>
164+
<ol className="htl__list">
165+
{manifest.runs.map((run) => (
166+
<li className={`htl__run htl__run--${run.kind}`} key={run.dir}>
167+
<div className="htl__meta">
168+
<span className="htl__kind">{KIND_LABEL[run.kind] ?? run.kind}</span>
169+
<span className="htl__date">{fmtDate(run.at)}</span>
170+
</div>
171+
{run.kind === 'improve' ? <ImproveRun id={id} run={run} /> : null}
172+
{run.kind === 'verify' ? <VerifyRun id={id} run={run} /> : null}
173+
</li>
174+
))}
175+
</ol>
176+
</section>
177+
);
178+
}

src/pages/DetailPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import { InfoPanel } from '../components/InfoPanel';
33
import { SceneStudio } from '../components/SceneStudio';
4+
import { HistoryTimeline } from '../components/HistoryTimeline';
45
import { Icon } from '../components/Icon';
56
import { GALLERY_HREF } from '../router';
67
import type { SampleEntry, SceneDescriptor } from '../types';
@@ -84,6 +85,7 @@ export function DetailPage({ id, samples, samplesLoaded }: DetailPageProps) {
8485
</header>
8586

8687
<SceneStudio id={id} fallbackScene={scene} />
88+
<HistoryTimeline id={id} />
8789
<InfoPanel scene={scene} open={infoOpen} onClose={() => setInfoOpen(false)} />
8890
</main>
8991
);

src/style.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,3 +1624,38 @@ a.gallery__card { text-decoration: none; color: inherit; display: flex; flex-dir
16241624
.studio__tab--active { color: var(--text); border-color: var(--accent); background: rgba(88,166,255,0.12); }
16251625
.studio__panes--mobile { display: block; }
16261626
@media (max-width: 899px) { .studio__eye { display: none; } }
1627+
1628+
/* ---- self-improvement history timeline (detail page) ---- */
1629+
.htl { padding: 20px clamp(16px, 4vw, 40px) 48px; border-top: 1px solid var(--border, #24292f); }
1630+
.htl__head { margin-bottom: 14px; }
1631+
.htl__title { margin: 0; font-size: 16px; letter-spacing: 0.02em; }
1632+
.htl__sub { margin: 4px 0 0; font-size: 12px; opacity: 0.65; }
1633+
.htl__list { list-style: none; margin: 0; padding: 0; position: relative; }
1634+
.htl__list::before { content: ''; position: absolute; left: 5px; top: 6px; bottom: 6px; width: 2px; background: color-mix(in srgb, currentColor 18%, transparent); }
1635+
.htl__run { position: relative; padding: 0 0 18px 24px; }
1636+
.htl__run::before { content: ''; position: absolute; left: 0; top: 4px; width: 12px; height: 12px; border-radius: 50%; background: #6b7280; }
1637+
.htl__run--improve::before { background: #58a6ff; }
1638+
.htl__run--verify::before { background: #3fb950; }
1639+
.htl__run--create::before { background: #d29922; }
1640+
.htl__meta { display: flex; gap: 10px; align-items: baseline; margin-bottom: 6px; }
1641+
.htl__kind { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.85; }
1642+
.htl__date { font-size: 11px; opacity: 0.55; font-variant-numeric: tabular-nums; }
1643+
.htl__strip { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; }
1644+
.htl__card { margin: 0; flex: 0 0 260px; max-width: 260px; border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 10px; overflow: hidden; background: color-mix(in srgb, currentColor 4%, transparent); }
1645+
.htl__render { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; }
1646+
.htl__cardbody { padding: 8px 10px 10px; }
1647+
.htl__cardhead { display: flex; gap: 8px; align-items: baseline; }
1648+
.htl__iter { font-size: 11px; opacity: 0.6; }
1649+
.htl__score { font-size: 13px; font-weight: 700; color: #58a6ff; font-variant-numeric: tabular-nums; }
1650+
.htl__verdict { font-size: 11px; opacity: 0.7; }
1651+
.htl__critique { margin: 6px 0 0; font-size: 11.5px; line-height: 1.45; opacity: 0.75; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
1652+
.htl__verify { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
1653+
.htl__badge { font-size: 11px; font-weight: 700; letter-spacing: 0.06em; padding: 2px 8px; border-radius: 999px; }
1654+
.htl__badge--pass { background: rgba(63, 185, 80, 0.15); color: #3fb950; }
1655+
.htl__badge--fail { background: rgba(248, 81, 73, 0.15); color: #f85149; }
1656+
.htl__checks { font-size: 11.5px; opacity: 0.7; }
1657+
.htl__src { font-size: 11.5px; }
1658+
.htl__log { flex-basis: 100%; font-size: 11px; }
1659+
.htl__log summary { cursor: pointer; opacity: 0.65; }
1660+
.htl__log pre { max-height: 280px; overflow: auto; font-size: 10.5px; line-height: 1.4; padding: 8px; border-radius: 8px; background: color-mix(in srgb, currentColor 6%, transparent); }
1661+
@media (max-width: 640px) { .htl__card { flex-basis: 78vw; max-width: 78vw; } }

0 commit comments

Comments
 (0)