app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Add the Rust source, flow definition, assets and install scripts
cf9809b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AI Flow run</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&family=DM+Sans:opsz,[email protected],400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: dark;
--bg: #0d1117;
--surface: #161b22;
--elevated: #1c2128;
--overlay: #21262d;
--border: #30363d;
--border-soft: #21262d;
--text: #e6edf3;
--text-body: #c9d1d9;
--text-2: #8b949e;
--text-3: #484f58;
--accent: #00b8b1;
--accent-bright: #00e6dc;
--accent-glow: rgba(0, 184, 177, .38);
--accent-wash: rgba(0, 184, 177, .10);
--on-accent: #05221f;
--purple: #8c6bf7;
--purple-wash: rgba(140, 107, 247, .13);
--blue: #64abff;
--sig: linear-gradient(100deg, #00e6dc, #39b8ff 60%, #8c6bf7);
--f-display: 'Familjen Grotesk', 'DM Sans', system-ui, sans-serif;
--f-body: 'Satoshi', 'DM Sans', system-ui, sans-serif;
--f-mono: 'JetBrains Mono', ui-monospace, monospace;
--pad: 20px;
--row: 32px;
--row-cur: 86px;
--gut: 64px;
--node-col: 40px;
--phase-col: 112px;
--ease: cubic-bezier(.65, 0, .35, 1);
--ease-out: cubic-bezier(.22, 1, .36, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--text-body); }
body {
font-family: var(--f-body);
font-size: 15px;
line-height: 1.4;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
min-height: 100vh;
}
body::before {
/* very soft teal light from the top, gives the ground some depth on camera */
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;
background:
radial-gradient(1200px 420px at 15% -120px, rgba(0, 184, 177, .07), transparent 70%);
}
.mono { font-family: var(--f-mono); font-variant-numeric: tabular-nums; }
svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
/* ---------- app bar (44px, three zones) ---------- */
.appbar {
position: relative; z-index: 2;
height: 44px; display: flex; align-items: center; gap: 12px;
padding: 0 var(--pad);
border-bottom: 1px solid var(--border-soft);
background: rgba(13, 17, 23, .7);
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-family: var(--f-display); font-weight: 600; font-size: 15px; letter-spacing: .1px; white-space: nowrap; cursor: default; }
.brand svg.i { width: 16px; height: 16px; color: var(--text); }
.brand .sep { color: var(--text-2); }
.vsep { width: 1px; height: 20px; background: var(--border); flex: none; }
.subject { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.subject b { color: var(--text); font-family: var(--f-display); font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.subject .meta2 { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; display: inline-flex; align-items: center; gap: 7px; }
.appbar .spacer { flex: 1; }
.chip {
display: inline-flex; align-items: center; gap: 7px; height: 26px; padding: 0 11px;
border: 1px solid var(--border); border-radius: 999px; color: var(--text-body);
font-size: 13px; font-weight: 500; white-space: nowrap; background: var(--surface);
}
.chip svg.i { width: 15px; height: 15px; color: var(--text); }
.live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 0 rgba(0, 184, 177, .6); animation: livepulse 2.4s ease-out infinite; align-self: center; }
.live.stale .dot { background: var(--text-2); animation: none; }
.live.idle .dot { background: var(--text-2); animation: none; }
.live .when { color: var(--text-2); font-family: var(--f-mono); font-size: 12px; }
@keyframes livepulse { 0% { box-shadow: 0 0 0 0 rgba(0, 184, 177, .55); } 70%, 100% { box-shadow: 0 0 0 7px rgba(0, 184, 177, 0); } }
/* ---------- hero header ---------- */
.hero { position: relative; z-index: 2; padding: 14px var(--pad) 0; display: grid; grid-template-columns: 1fr auto; gap: 6px 20px; align-items: center; }
.hero h1 {
margin: 0; font-family: var(--f-display); font-weight: 700; color: var(--text);
font-size: 26px; line-height: 1.12; letter-spacing: -.2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.runline { display: flex; align-items: baseline; gap: 0; margin-top: 4px; min-width: 0; overflow: hidden; white-space: nowrap; font-family: var(--f-mono); font-size: 12.5px; color: var(--text-2); }
.runline span { flex: none; white-space: nowrap; }
.runline span:not(.first)::before { content: "\00b7"; margin: 0 8px; color: var(--text-2); }
.runline span[hidden] { display: none; }
.runline .thread { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-body); }
.finishline { display: flex; align-items: center; gap: 8px; margin-top: 5px; color: var(--text-2); font-size: 14px; min-width: 0; }
.finishline svg.i { color: var(--text); width: 17px; height: 17px; }
.finishline b { color: var(--text-body); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ring { position: relative; width: 84px; height: 84px; grid-row: span 2; }
.ring svg { width: 84px; height: 84px; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 7; }
.ring .track { stroke: var(--overlay); }
.ring .redo { stroke: var(--purple); opacity: .85; transition: stroke-dasharray .9s var(--ease), stroke-dashoffset .9s var(--ease); }
.ring .done { stroke: url(#ringgrad); stroke-linecap: round; transition: stroke-dasharray .9s var(--ease); }
.ring .pct { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring .pct b { font-family: var(--f-mono); font-weight: 600; font-size: 21px; color: var(--text); line-height: 1; }
.ring .pct b small { font-size: 12px; color: var(--text-2); font-weight: 500; margin-left: 1px; }
.ring .pct span { font-size: 11px; color: var(--text-2); margin-top: 3px; letter-spacing: .4px; }
.stats { grid-column: 1 / 2; display: flex; flex-wrap: wrap; gap: 6px 26px; align-items: baseline; margin-top: 4px; }
.stat { display: flex; align-items: baseline; gap: 7px; white-space: nowrap; font-size: 16px; color: var(--text-2); }
.stat b { font-family: var(--f-mono); font-weight: 600; color: var(--text); font-size: 26px; line-height: 1; letter-spacing: -.5px; transition: color .4s; }
.stat.stepno { color: var(--text-body); font-family: var(--f-display); font-weight: 600; font-size: 19px; }
.stat.stepno b { color: var(--accent-bright); font-size: 30px; }
.stat.loops b { color: var(--purple); }
.stat.loops.none b { color: var(--text-2); }
.stat .unit { font-size: 14px; }
/* ---------- latest loop callout ---------- */
.callout-wrap { position: relative; z-index: 2; padding: 0 var(--pad); display: grid; grid-template-rows: 0fr; transition: grid-template-rows .6s var(--ease-out), margin .6s var(--ease-out); margin-top: 0; }
.callout-wrap.on { grid-template-rows: 1fr; margin-top: 12px; }
.callout-wrap > div { overflow: hidden; min-height: 0; }
.callout {
display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: start;
padding: 8px 14px 9px 12px; border-radius: 12px; cursor: default;
background: linear-gradient(90deg, var(--purple-wash), rgba(140, 107, 247, .04));
border: 1px solid rgba(140, 107, 247, .35);
transition: border-color .2s ease, background .2s ease;
}
.callout:hover { border-color: rgba(140, 107, 247, .7); }
.callout .ic { grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: rgba(140, 107, 247, .18); color: var(--text); margin-top: 1px; }
.callout .ic svg.i { width: 17px; height: 17px; }
.callout .head { font-size: 14px; color: var(--purple); font-weight: 600; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.callout .head em { font-style: normal; color: var(--text); }
.callout .head .t { margin-left: auto; color: var(--text-2); font-family: var(--f-mono); font-size: 12px; font-weight: 400; }
.callout .why { color: var(--text-body); font-size: 15px; line-height: 1.38; }
.callout.fresh { animation: calloutin 1.4s var(--ease-out); }
@keyframes calloutin { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .55); } 100% { box-shadow: 0 0 0 14px rgba(140, 107, 247, 0); } }
/* ---------- stage: the timeline ---------- */
.stage { position: relative; z-index: 1; margin: 10px var(--pad) 0; padding: 0 0 8px; }
.overlay { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 4; }
.spine, .spine-fill { position: absolute; z-index: 0; border-radius: 2px; }
.spine { background: var(--border); }
.spine-fill { background: linear-gradient(180deg, rgba(0, 184, 177, .7), var(--accent-bright)); transition: height .8s var(--ease), width .8s var(--ease); box-shadow: 0 0 10px rgba(0, 184, 177, .25); }
.halo { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; pointer-events: none; transition: transform .85s var(--ease); }
.halo::before, .halo::after {
content: ""; position: absolute; left: -22px; top: -22px; width: 44px; height: 44px; border-radius: 50%;
border: 2px solid var(--accent); opacity: 0;
animation: ping 2.2s var(--ease-out) infinite;
}
.halo::after { animation-delay: 1.1s; }
.halo .glow { position: absolute; left: -26px; top: -26px; width: 52px; height: 52px; border-radius: 50%; background: radial-gradient(circle, var(--accent-glow), transparent 68%); }
.halo.hide { opacity: 0; }
@keyframes ping { 0% { transform: scale(.55); opacity: .85; } 100% { transform: scale(1.45); opacity: 0; } }
ol.steps { list-style: none; margin: 0; padding: 0; position: relative; z-index: 3; }
li.step {
position: relative;
display: grid; grid-template-columns: var(--gut) var(--node-col) minmax(0, 1fr) auto var(--phase-col);
align-items: center; height: var(--row);
transition: height .6s var(--ease);
}
li.step.cur { height: var(--row-cur); }
li.step .rowbg {
position: absolute; left: calc(var(--gut) - 8px); right: calc(var(--phase-col) - 10px); top: 3px; bottom: 3px;
border-radius: 12px; opacity: 0; transition: opacity .5s ease;
background: linear-gradient(90deg, rgba(0, 184, 177, .14), rgba(0, 184, 177, .03) 70%);
border: 1px solid rgba(0, 184, 177, .34);
box-shadow: 0 8px 30px rgba(0, 0, 0, .25);
}
li.step.cur .rowbg { opacity: 1; }
li.step:not(.cur):hover .rowbg { opacity: .45; background: var(--surface); border-color: var(--border); box-shadow: none; }
.node {
grid-column: 2; justify-self: center; position: relative; z-index: 2;
width: 22px; height: 22px; border-radius: 50%;
display: grid; place-items: center;
background: var(--bg); border: 2px solid var(--text-3);
transition: background .45s ease, border-color .45s ease, transform .45s var(--ease-out), width .45s var(--ease-out), height .45s var(--ease-out), box-shadow .45s ease;
}
.node > svg { grid-area: 1 / 1; }
.node svg { width: 14px; height: 14px; fill: none; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; opacity: 0; transition: opacity .3s ease; }
.node .ck { stroke: var(--on-accent); stroke-dasharray: 20; stroke-dashoffset: 20; }
.node .rd { stroke: var(--text); stroke-width: 2.4; width: 13px; height: 13px; }
.node .cu { stroke: none; fill: var(--accent-bright); width: 10px; height: 10px; }
li.done .node { background: var(--accent); border-color: var(--accent); }
li.done .node .ck { opacity: 1; stroke-dashoffset: 0; transition: stroke-dashoffset .5s var(--ease-out) .1s, opacity .1s; }
li.redo .node { background: #17142b; border-color: var(--purple); }
li.redo .node .rd { opacity: 1; }
li.cur .node { width: 30px; height: 30px; background: var(--bg); border: 3px solid var(--accent-bright); box-shadow: 0 0 0 5px rgba(0, 230, 220, .14), 0 0 24px rgba(0, 230, 220, .55); }
li.cur .node svg.cu { opacity: 1; }
li.just-done .node { animation: donepop .9s var(--ease-out); }
@keyframes donepop { 0% { transform: scale(.6); box-shadow: 0 0 0 0 rgba(0, 184, 177, .7); } 45% { transform: scale(1.18); } 100% { transform: scale(1); box-shadow: 0 0 0 16px rgba(0, 184, 177, 0); } }
li.just-redo .node { animation: redopop 1s var(--ease-out); }
@keyframes redopop { 0% { box-shadow: 0 0 0 0 rgba(140, 107, 247, .7); } 100% { box-shadow: 0 0 0 16px rgba(140, 107, 247, 0); } }
.label { grid-column: 3; min-width: 0; padding-left: 8px; position: relative; z-index: 2; }
.label .t { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 16px; font-weight: 500; color: var(--text-body); transition: color .4s ease, font-size .45s var(--ease); }
.label .t .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.label .n { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); width: 20px; flex: none; text-align: right; margin-right: 2px; transition: color .4s; }
li.todo .label .t { color: var(--text-2); }
li.redo .label .t { color: var(--text-body); }
li.cur .label .t { font-family: var(--f-display); font-size: 23px; font-weight: 700; color: var(--text); letter-spacing: -.1px; }
li.cur .label .n { color: var(--accent-bright); }
.badge {
flex: none; font-family: var(--f-mono); font-size: 12px; font-weight: 600; line-height: 1;
padding: 3px 6px 3px 5px; border-radius: 999px; display: inline-flex; align-items: center; gap: 3px;
color: var(--purple); background: var(--purple-wash); border: 1px solid rgba(140, 107, 247, .4);
transition: opacity .3s ease, transform .3s var(--ease-out);
}
.badge svg { width: 11px; height: 11px; fill: none; stroke: var(--text); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.badge.off { display: none; }
li.cur .badge { font-size: 13px; }
.label .does {
font-size: 15px; color: var(--text-body); line-height: 1.35; margin-top: 3px; padding-left: 30px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
max-height: 0; opacity: 0; transition: opacity .4s ease .15s, max-height .5s var(--ease);
}
li.cur .label .does { max-height: 42px; opacity: 1; }
.meta { grid-column: 4; position: relative; z-index: 2; padding: 0 14px 0 10px; font-family: var(--f-mono); font-size: 13px; color: var(--text-2); white-space: nowrap; text-align: right; }
li.cur .meta { color: var(--text-body); }
.meta .redo-tag { color: var(--purple); font-family: var(--f-body); font-size: 13px; font-weight: 600; }
/* finish row */
li.finish .node { width: 30px; height: 30px; border-radius: 9px; background: var(--surface); border: 2px solid var(--border); color: var(--text); }
li.finish .node svg.fl { width: 16px; height: 16px; opacity: 1; stroke: currentColor; stroke-width: 2; }
li.finish .label .t { font-family: var(--f-display); font-weight: 600; color: var(--text-body); font-size: 16px; }
li.finish .label .t .nm { white-space: nowrap; }
li.finish .label .t .k { color: var(--text-2); font-weight: 500; }
li.finish.won .node { background: var(--sig); border-color: transparent; color: var(--bg); box-shadow: 0 0 26px rgba(57, 184, 255, .45); }
li.finish.won .label .t { color: var(--text); font-size: 23px; font-weight: 700; }
li.finish.won { height: var(--row-cur); }
li.finish.won .rowbg { opacity: 1; background: linear-gradient(100deg, rgba(0, 230, 220, .14), rgba(57, 184, 255, .08) 60%, rgba(140, 107, 247, .10)); border-color: rgba(57, 184, 255, .45); }
body.wide li.finish.won { height: auto; }
body.wide li.finish.won .label .t { font-size: 15px; }
.stat.stepno.won b { background: var(--sig); -webkit-background-clip: text; background-clip: text; color: transparent; }
/* phase brackets */
.phase { position: absolute; z-index: 2; pointer-events: auto; transition: top .6s var(--ease), height .6s var(--ease), left .6s var(--ease), width .6s var(--ease); }
.phase .bar { position: absolute; background: var(--border); border-radius: 2px; transition: background .5s ease; }
.phase .nm { position: absolute; font-family: var(--f-display); font-weight: 600; font-size: 14px; color: var(--text-2); white-space: nowrap; transition: color .5s; }
.phase .ct { position: absolute; font-family: var(--f-mono); font-size: 12px; color: var(--text-2); white-space: nowrap; }
.phase.active .bar { background: var(--accent); box-shadow: 0 0 8px rgba(0, 184, 177, .45); }
.phase.active .nm { color: var(--text); }
.phase.complete .bar { background: rgba(0, 184, 177, .75); }
.phase.complete .nm { color: var(--text-body); }
.phase.rework .bar { background: var(--purple); }
.phase.rework .nm { color: var(--text-body); }
/* loop arcs */
.arc { fill: none; stroke: var(--purple); stroke-width: 2.4; stroke-linecap: round; opacity: .5; transition: opacity .3s ease, stroke-width .3s ease; }
.arc.latest { opacity: 1; stroke: var(--purple); filter: drop-shadow(0 0 5px rgba(140, 107, 247, .55)); }
.arc.new { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.3s var(--ease) forwards; }
.arrow { fill: var(--purple); opacity: .5; transition: opacity .3s ease; }
.arrow.latest { fill: var(--purple); opacity: 1; }
.arrow.new { animation: fadein .3s ease 1.15s both; }
.arc-hit { fill: none; stroke: transparent; stroke-width: 16; pointer-events: stroke; cursor: help; }
.loopbadge { pointer-events: auto; cursor: help; }
.loopbadge circle { fill: #1b1735; stroke: var(--purple); stroke-width: 1.6; }
.loopbadge.latest circle { fill: var(--purple); stroke: var(--purple); }
.loopbadge text { font-family: var(--f-mono); font-size: 11.5px; font-weight: 600; fill: var(--purple); text-anchor: middle; dominant-baseline: central; }
.loopbadge.latest text { fill: #0d1117; }
.loopbadge.new { animation: fadein .35s ease 1.1s both; }
.arcgrp.hl .arc { opacity: 1; stroke-width: 3.4; }
.arcgrp.hl .arrow { opacity: 1; }
.arcgrp.dim .arc, .arcgrp.dim .arrow { opacity: .18; }
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes fadein { from { opacity: 0; } }
body.notrans *, body.notrans *::before, body.notrans *::after { transition: none !important; }
.arcpill {
position: absolute; z-index: 4; left: 0; top: 0; display: none; align-items: center; gap: 9px;
max-width: min(900px, calc(100% - 16px)); padding: 6px 18px 6px 6px; border-radius: 18px; cursor: help;
background: #1a1631; border: 1.5px solid var(--purple); color: var(--text-body);
font-size: 14.5px; line-height: 1.25; box-shadow: 0 6px 24px rgba(0, 0, 0, .45), 0 0 18px rgba(140, 107, 247, .25);
transition: left .6s var(--ease), top .6s var(--ease), opacity .4s ease;
}
.arcpill .num { flex: none; width: 22px; height: 22px; border-radius: 50%; background: var(--purple); color: #0d1117; font-family: var(--f-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }
.arcpill .tx { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.arcpill b { color: var(--purple); font-weight: 600; }
body.wide .arcpill.on { display: flex; }
body.wide .loopbadge.latest { display: none; }
.arcpill.new { animation: fadein .4s ease 1.2s both; }
/* ---------- wide layout: horizontal timeline ---------- */
.lower { display: none; }
body.wide { --pad: 32px; }
body.wide .hero { grid-template-columns: minmax(0, 1fr) auto auto; padding-top: 22px; }
body.wide .hero h1 { font-size: 32px; }
body.wide .ring { grid-row: 1 / 3; grid-column: 3; }
body.wide .stats { grid-column: 2; grid-row: 1 / 3; gap: 10px 34px; margin: 0 6px 0 0; flex-wrap: nowrap; }
body.wide .stat b { font-size: 30px; }
body.wide .stat.stepno b { font-size: 36px; }
body.wide .callout-wrap { display: none; }
body.wide .stage { margin-top: 22px; background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 18px 16px; }
body.wide ol.steps { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
body.wide li.step, body.wide li.step.cur {
height: auto; display: flex; flex-direction: column; align-items: center; text-align: center;
padding-top: var(--htop); grid-template-columns: none;
}
body.wide .node { flex: none; }
body.wide li.step .rowbg { left: 3px; right: 3px; top: calc(var(--htop) - 18px); bottom: -6px; }
body.wide .label { padding: 10px 2px 0; width: 100%; }
body.wide .label .t { flex-direction: column; gap: 5px; font-size: 14px; line-height: 1.2; padding: 0 2px; hyphens: auto; font-size: 13.5px; }
body.wide .label .t .nm { white-space: normal; overflow: visible; text-wrap: balance; }
body.wide li.cur .label .t { font-size: 16px; color: var(--accent-bright); }
body.wide .label .n { width: auto; margin: 0; text-align: center; }
body.wide .label .does { display: none; }
body.wide .meta { padding: 4px 0 0; text-align: center; font-size: 12px; }
body.wide li.finish .label .t .k { display: block; }
body.wide .lower { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; margin: 18px var(--pad) 24px; position: relative; z-index: 2; }
.card { background: var(--surface); border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px 22px; min-width: 0; }
.card .eyebrow { font-family: var(--f-display); font-size: 12px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-2); display: flex; align-items: center; gap: 8px; }
.card .eyebrow svg.i { width: 15px; height: 15px; color: var(--text); }
.now h2 { font-family: var(--f-display); font-size: 34px; font-weight: 700; color: var(--text); margin: 10px 0 6px; line-height: 1.1; display: flex; align-items: center; gap: 12px; }
.now h2 .n:empty { display: none; }
.now h2 .n { font-family: var(--f-mono); color: var(--accent-bright); font-size: 22px; font-weight: 600; }
.now p { margin: 0; font-size: 19px; line-height: 1.45; color: var(--text-body); }
.now .facts { display: flex; gap: 22px; margin-top: 14px; color: var(--text-2); font-size: 14px; flex-wrap: wrap; }
.now .facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }
.looplist { margin: 10px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; }
.looplist li { display: grid; grid-template-columns: 26px 1fr auto; gap: 3px 12px; align-items: baseline; padding: 9px 12px; border-radius: 10px; border: 1px solid transparent; cursor: default; transition: background .2s ease, border-color .2s ease; }
.looplist li:hover { background: var(--elevated); }
.looplist li.latest { background: var(--purple-wash); border-color: rgba(140, 107, 247, .35); }
.looplist .num { grid-row: span 2; align-self: start; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-family: var(--f-mono); font-size: 12px; font-weight: 600; color: var(--purple); border: 1.5px solid var(--purple); }
.looplist li.latest .num { background: var(--purple); color: #0d1117; }
.looplist .path { font-weight: 600; color: var(--text); font-size: 15px; display: flex; gap: 7px; align-items: center; }
.looplist .path svg.i { width: 15px; height: 15px; color: var(--text); }
.looplist .tm { font-family: var(--f-mono); font-size: 12px; color: var(--text-2); }
.looplist .why { grid-column: 2 / 4; color: var(--text-body); font-size: 15px; }
.looplist .empty { color: var(--text-2); font-size: 15px; display: block; padding: 6px 0; }
/* ---------- waiting state ---------- */
.waiting { display: none; position: fixed; inset: 0; z-index: 5; place-items: center; text-align: center; padding: 24px; }
body.waiting-on .waiting { display: grid; }
body.waiting-on .appbar ~ *:not(.waiting) { visibility: hidden; }
.waiting .orb { width: 74px; height: 74px; margin: 0 auto 22px; border-radius: 50%; position: relative; display: grid; place-items: center; color: var(--text); }
.waiting .orb::before { content: ""; position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--border); border-top-color: var(--accent); animation: spin 2.4s linear infinite; }
.waiting .orb svg.i { width: 28px; height: 28px; }
.waiting h2 { font-family: var(--f-display); font-weight: 700; font-size: 26px; color: var(--text); margin: 0 0 8px; }
.waiting p { margin: 0 auto; max-width: 420px; color: var(--text-2); font-size: 15px; line-height: 1.5; }
.waiting code { font-family: var(--f-mono); font-size: 13px; color: var(--text-body); background: var(--surface); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- tooltip ---------- */
#tip {
position: fixed; z-index: 99999; left: 0; top: 0; display: none; opacity: 0; pointer-events: none;
max-width: min(340px, calc(100vw - 24px));
background: rgba(33, 38, 45, .97); color: var(--text-body);
border: 1px solid rgba(0, 184, 177, .28); border-radius: 10px;
padding: 10px 13px; font: 400 14px/1.45 var(--f-body);
box-shadow: 0 14px 40px rgba(0, 0, 0, .55);
transition: opacity .18s ease, transform .18s ease;
}
#tip.loopy { border-color: rgba(140, 107, 247, .5); }
#tip.on { display: block; }
#tip.shown { opacity: 1; }
#tip .tt { font-family: var(--f-display); font-weight: 700; font-size: 15px; color: var(--text); margin-bottom: 3px; display: flex; gap: 8px; align-items: baseline; }
#tip .tt .s { margin-left: auto; font-family: var(--f-body); font-weight: 600; font-size: 12px; }
#tip .s.done { color: var(--accent); } #tip .s.current { color: var(--accent-bright); } #tip .s.redo { color: var(--purple); } #tip .s.todo { color: var(--text-2); }
#tip .ft { margin-top: 6px; color: var(--text-2); font-size: 12.5px; font-family: var(--f-mono); }
/* phones and very narrow panes */
@media (max-width: 620px) {
:root { --pad: 14px; --phase-col: 0px; --row-cur: 96px; }
.phase { display: none; }
li.step .rowbg { right: 0; }
.hero h1 { font-size: 22px; white-space: normal; }
.ring { width: 70px; height: 70px; } .ring svg { width: 70px; height: 70px; }
.stat b { font-size: 22px; } .stat.stepno b { font-size: 26px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; animation-delay: 0s !important; transition-duration: .001ms !important; transition-delay: 0s !important; }
.halo::before, .halo::after { display: none; }
.arc.new { stroke-dashoffset: 0; }
}
/* Timeline first; supporting project evidence follows the workflow. */
body { --pad: 28px; }
.hero, body.wide .hero { padding-top: 14px; padding-bottom: 6px; }
.hero h1, body.wide .hero h1 { font-size: clamp(24px, 2.5vw, 32px); white-space: normal; letter-spacing: -.7px; }
.finishline { margin-top: 10px; }
.overview { position: relative; display: grid; grid-template-columns: minmax(0,1.15fr) minmax(0,1fr); gap: 20px; margin: 24px var(--pad); }
.overview .card { padding: 24px; }
.board-preview { min-height: 250px; margin: 0; display: grid; place-items: center; overflow: hidden; background: radial-gradient(ellipse at center, #163534, #111820 75%); border: 1px solid var(--border-soft); border-radius: 16px; position: relative; }
.board-preview img { width: 100%; height: 290px; object-fit: contain; }
.board-preview figcaption { position: absolute; bottom: 0; left: 0; right: 0; padding: 10px 16px; background: #0d1117e8; font-size: 12px; color: var(--text-2); }
.preview-empty { text-align: center; padding: 30px; color: var(--text-2); }
.preview-empty svg { width: 56px; height: 56px; margin-bottom: 12px; color: var(--accent); }
.preview-empty strong { display: block; color: var(--text); font-size: 20px; margin-bottom: 6px; }
.overview .now h2 { font-size: 30px; margin-top: 18px; }
.overview .now p { font-size: 16px; }
.overview .now { border-top: 2px solid var(--accent); }
.recent-work { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-soft); }
.recent-work ul { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 8px; }
.recent-work li { color: #8bd5a5; font-size: 12px; padding: 5px 9px; background: #16271f; border-radius: 6px; }
.section-heading { margin: 12px var(--pad) 4px; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.section-heading h2 { font-size: 18px; margin: 0; color: var(--text); }
.section-heading span { font-size: 12px; color: var(--text-2); }
.community { position: relative; margin: 24px var(--pad) 30px; padding: 22px 24px; display: flex; gap: 24px; align-items: center; justify-content: space-between; border: 1px solid var(--border-soft); border-radius: 14px; background: var(--surface); }
.community h2 { margin: 0 0 6px; font-size: 18px; color: var(--text); }
.community p { margin: 0; max-width: 570px; color: var(--text-2); font-size: 14px; }
.community nav { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; flex-shrink: 0; }
.community a { color: var(--text-body); text-decoration: none; font-size: 13px; border-radius: 6px; padding: 7px 2px; }
.community a.primary { background: var(--accent); color: var(--on-accent); padding: 10px 15px; font-weight: 700; }
.community a:hover { color: var(--accent-bright); }
.community a.primary:hover { color: var(--on-accent); background: var(--accent-bright); }
.community a:active { transform: translateY(1px); }
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 4px; }
.prompt-hint { font: inherit; border: 0; background: transparent; color: var(--text-2); padding: 0; width: 18px; height: 18px; cursor: pointer; }
.prompt-hint:hover { color: var(--accent-bright); }
.prompt-hint:active { transform: scale(.92); }
#copy-feedback { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--elevated); padding: 10px 18px; border-radius: 8px; z-index: 100; }
body.wide .lower { grid-template-columns: 1fr; }
body.wide .stage { margin-top: 14px; }
@media (max-width: 800px) {
body { --pad: 18px; }
.overview { grid-template-columns: 1fr; gap: 14px; }
.board-preview { min-height: 210px; }
.board-preview img { height: 230px; }
.community { flex-direction: column; align-items: flex-start; gap: 16px; }
.section-heading { flex-wrap: wrap; }
}
.rules-card { margin: 0 var(--pad) 24px; }
.rules-card .card { padding: 20px 22px; }
.rules-card .rc-body { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) minmax(0,1.2fr); gap: 16px; margin-top: 14px; align-items: start; }
.rules-card figure { margin: 0; background: #111820; border: 1px solid var(--border-soft); border-radius: 12px; padding: 10px; min-width: 0; }
.rules-card figure svg { display: block; width: 100%; height: 190px; }
.rules-card figcaption { font-size: 12px; color: var(--text-2); margin-top: 6px; }
.rules-card .rc-facts { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13px; color: var(--text-2); min-width: 0; }
.rules-card .rc-facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }
.rules-card .rc-facts li.flag b, .rules-card .rc-flags li { color: var(--purple); }
.rules-card .rc-flags { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; font-size: 13px; overflow-wrap: anywhere; }
.rules-card .rc-flags li::before { content: "! "; font-weight: 700; }
.rules-card .rc-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); }
@media (max-width: 800px) { .rules-card .rc-body { grid-template-columns: 1fr; } }
</style>
</head>
<body class="waiting-on">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<linearGradient id="ringgrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#00b8b1"/><stop offset="1" stop-color="#00e6dc"/>
</linearGradient>
<symbol id="i-flow" viewBox="0 0 24 24"><circle cx="5" cy="5" r="2.2"/><circle cx="5" cy="19" r="2.2"/><path d="M5 7.5v9M8 5h6a5 5 0 0 1 0 10h-3"/><path d="M13 12.5 10.5 15l2.5 2.5"/></symbol>
<symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></symbol>
<symbol id="i-loop" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2.5v3.5M15 2.5v3.5M9 18v3.5M15 18v3.5M2.5 9H6M2.5 15H6M18 9h3.5M18 15h3.5"/></symbol>
<symbol id="i-now" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></symbol>
</defs>
</svg>
<header class="appbar">
<div class="brand" data-tipkey="brand"><svg class="i"><use href="#i-flow"/></svg>AI Flow <span class="sep">·</span> Timeline</div>
<div class="vsep"></div>
<div class="subject"><b id="engine" data-tipkey="engine" style="display:none"><span></span></b><span class="meta2 live idle" id="live" data-tipkey="live"><span class="dot"></span><span class="lbl">Live</span><span class="when" id="when"></span></span></div>
</header>
<section class="hero">
<div style="min-width:0">
<h1 id="title">AI Flow run</h1>
<div class="runline" id="runline" data-tipkey="runline"><span id="rl-board" hidden></span><span id="rl-lane" hidden></span><span class="thread" id="rl-thread" hidden></span><span id="rl-folder" hidden></span></div>
<div class="finishline" data-tipkey="finish"><svg class="i"><use href="#i-flag"/></svg><span>Finish line:</span><b id="finish"></b></div>
</div>
<div class="stats" id="stats">
<div class="stat stepno" data-tipkey="stepno">Step <b id="s-step">0</b> of <span id="s-total">0</span></div>
<div class="stat" data-tipkey="togo"><b id="s-togo">0</b> to go</div>
<div class="stat" data-tipkey="minutes"><b id="s-min">0.0</b> <span class="unit">AI min</span></div>
<div class="stat loops none" id="s-loops-w" data-tipkey="loops"><b id="s-loops">0</b> <span id="s-loops-l">loop backs</span></div>
</div>
<div class="ring" data-tipkey="ring">
<svg viewBox="0 0 84 84"><circle class="track" cx="42" cy="42" r="36"/><circle class="redo" id="ring-redo" cx="42" cy="42" r="36"/><circle class="done" id="ring-done" cx="42" cy="42" r="36"/></svg>
<div class="pct"><b id="pct">0<small>%</small></b><span>done</span></div>
</div>
</section>
<div class="section-heading"><h2>From idea to tested board</h2><span>Workflow progress · engineering checks still apply</span></div>
<div class="callout-wrap" id="callout-wrap"><div><div class="callout" id="callout" data-tipkey="callout">
<div class="ic"><svg class="i"><use href="#i-loop"/></svg></div>
<div class="head"><span id="co-n">Loop back</span><em id="co-path"></em><span class="t" id="co-t"></span></div>
<div class="why" id="co-why"></div>
</div></div></div>
<main class="stage" id="stage">
<div class="spine" id="spine"></div>
<div class="spine-fill" id="spine-fill"></div>
<svg class="overlay" id="overlay"></svg>
<div class="halo hide" id="halo"><div class="glow"></div></div>
<div id="phases"></div>
<div class="arcpill" id="arcpill"><span class="num"></span><span class="tx"></span></div>
<ol class="steps" id="steps"></ol>
</main>
<section class="overview" aria-label="Project overview">
<figure class="board-preview">
<div class="preview-empty" id="preview-empty"><svg class="i"><use href="#i-cpu"/></svg><strong id="project-phase">Define the project</strong><p id="project-context">Requirements, decisions, and evidence guide each stage.</p></div>
<img id="board-image" hidden alt="Saved board view">
<figcaption id="board-caption" hidden></figcaption>
</figure>
<div class="card now">
<div class="eyebrow"><svg class="i"><use href="#i-now"/></svg>Now working on</div>
<h2><span class="n" id="now-n"></span><span id="now-title"></span></h2>
<p id="now-does"></p>
<div class="facts" id="now-facts"></div>
<div class="recent-work"><div class="eyebrow">Completed in this workflow</div><ul id="recent-work"></ul></div>
</div>
</section>
<section class="rules-card" id="rules-card" hidden aria-label="Design rules at start">
<div class="card">
<div class="eyebrow"><svg class="i"><use href="#i-cpu"/></svg>Design rules at start <span id="rc-lane"></span></div>
<div class="rc-body">
<figure><svg id="rc-board" role="img" aria-label="Board outline to scale"></svg><figcaption id="rc-board-cap"></figcaption></figure>
<figure><svg id="rc-detail" role="img" aria-label="Minimum trace, clearance, via pad and drill to scale"></svg><figcaption id="rc-detail-cap"></figcaption></figure>
<ul class="rc-facts" id="rc-facts"></ul>
</div>
<ul class="rc-flags" id="rc-flags"></ul>
<p class="rc-note" id="rc-note"></p>
</div>
</section>
<section class="lower" id="lower">
<div class="card">
<div class="eyebrow"><svg class="i"><use href="#i-loop"/></svg>Loop backs: where the AI went back to rework</div>
<ul class="looplist" id="looplist"></ul>
</div>
</section>
<section class="community" aria-label="Contribute to AI Flow">
<div><h2>Built together. Better for everyone. <button class="prompt-hint" id="contribute-prompt" title="Help me contribute an improvement to adom-aiflow through a pull request." aria-label="Copy contribution prompt">ⓘ</button></h2>
<p>AI Flow is MIT open source. Share a fix, a skill, or a lesson from your builds so the whole community benefits.</p></div>
<nav aria-label="Open source links"><a href="https://wiki.adom.inc/adom/adom-aiflow/files/LICENSE" target="_blank" rel="noopener noreferrer">MIT license</a><a href="https://wiki.adom.inc/adom/adom-aiflow" target="_blank" rel="noopener noreferrer">Source</a><a class="primary" href="https://wiki.adom.inc/adom/adom-aiflow/prs" target="_blank" rel="noopener noreferrer">Contribute a PR</a></nav>
</section>
<div id="copy-feedback" role="status" hidden></div>
<div class="waiting" id="waiting">
<div>
<div class="orb"><svg class="i"><use href="#i-flow"/></svg></div>
<h2>Waiting for the flow to start</h2>
<p>The timeline appears here as soon as adom-aiflow writes <code>timeline.json</code>. It checks again every second.</p>
</div>
</div>
<div id="tip" role="tooltip"></div>
<script>
(() => {
'use strict';
const $ = id => document.getElementById(id);
const SVGNS = 'http://www.w3.org/2000/svg';
const params = new URLSearchParams(location.search);
// Relative on purpose: the page is served behind a proxy path. Never add a leading slash.
const SRC = (params.get('src') || 'timeline.json').replace(/^\/+/, '');
const WIDE_AT = 1700;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const STATUSES = { todo: 1, current: 1, done: 1, redo: 1 };
const STATUS_LABEL = { done: 'Done', current: 'Working now', redo: 'Needs rework', todo: 'Not started' };
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const fmtMin = m => (Math.round((+m || 0) * 10) / 10).toFixed(1);
const fmtClock = iso => {
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
};
const fmtClockS = iso => {
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
};
const svgEl = (tag, attrs) => {
const el = document.createElementNS(SVGNS, tag);
for (const k in attrs) el.setAttribute(k, attrs[k]);
return el;
};
// Design-rule sanity card (presentation.json rulesCard, written by `start`). Read-only.
const num = v => (typeof v === 'number' && isFinite(v)) ? v : null;
const niceBar = len => { const c = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200]; return c.reduce((b, x) => x <= len ? x : b, c[0]); };
function renderRulesCard(card) {
const sec = $('rules-card');
if (!card || typeof card !== 'object') { sec.hidden = true; return; }
sec.hidden = false;
$('rc-lane').textContent = card.lane ? '· ' + card.lane + ' lane' : '';
const ink = 'var(--text-2)', cu = '#c9a227', teal = '#00b8b1';
// board outline at its own scale with a scale bar
const bsvg = $('rc-board'); bsvg.replaceChildren();
const b = card.board || {};
const w = num(b.widthMm), h = num(b.heightMm);
const d = card.draw || {};
const t = num(d.traceMm) || 0, c = num(d.clearanceMm) || 0, v = num(d.viaPadMm) || 0, dr = num(d.drillMm) || 0;
const win = Math.max(3, 2 * t + c + v + 1.2);
if (w && h) {
const m = Math.max(w, h) * 0.08;
bsvg.setAttribute('viewBox', [-m, -m, w + 2 * m, h + 2.6 * m].join(' '));
const pts = Array.isArray(b.outline) ? b.outline.filter(p => Array.isArray(p) && num(p[0]) != null && num(p[1]) != null) : [];
if (pts.length > 2) bsvg.appendChild(svgEl('polygon', {points: pts.map(p => p[0] + ',' + p[1]).join(' '), fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));
else bsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: w, height: h, fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));
// the detail window, same scale as the board
bsvg.appendChild(svgEl('rect', {x: w - win, y: h - win, width: win, height: win, fill: 'none', stroke: '#8c6bf7', 'stroke-width': Math.max(w, h) / 300}));
const bar = niceBar(w / 3);
bsvg.appendChild(svgEl('rect', {x: 0, y: h + m, width: bar, height: m * 0.35, fill: ink}));
const lbl = svgEl('text', {x: bar + m * 0.4, y: h + m * 1.35, fill: ink, 'font-size': m * 0.9, 'font-family': 'JetBrains Mono, monospace'}); lbl.textContent = bar + ' mm'; bsvg.appendChild(lbl);
$('rc-board-cap').textContent = 'Board ' + w + ' x ' + h + ' mm; the purple square is the detail window (' + win.toFixed(1) + ' mm).';
} else { $('rc-board-cap').textContent = 'No board outline read.'; }
// the smallest features, drawn at one scale in the detail window
const dsvg = $('rc-detail'); dsvg.replaceChildren();
const pad = win * 0.08;
dsvg.setAttribute('viewBox', [-pad, -pad, win + 2 * pad, win + 2 * pad].join(' '));
dsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: win, height: win, fill: '#16352f', stroke: '#8c6bf7', 'stroke-width': win / 200}));
const x0 = 0.4, len = win * 0.62;
if (t > 0) {
dsvg.appendChild(svgEl('rect', {x: x0, y: 0.3, width: t, height: len, fill: cu}));
dsvg.appendChild(svgEl('rect', {x: x0 + t + c, y: 0.3, width: t, height: len, fill: cu}));
}
if (v > 0) {
const cx = x0 + 2 * t + c + 0.6 + v / 2, cy = 0.3 + v / 2;
dsvg.appendChild(svgEl('circle', {cx, cy, r: v / 2, fill: cu}));
if (dr > 0) dsvg.appendChild(svgEl('circle', {cx, cy, r: Math.min(dr, v) / 2, fill: '#0d1117'}));
}
const bar = niceBar(win / 3);
dsvg.appendChild(svgEl('rect', {x: x0, y: win - 0.45, width: bar, height: win / 60, fill: ink}));
const dl = svgEl('text', {x: x0 + bar + 0.15, y: win - 0.3, fill: ink, 'font-size': win / 16, 'font-family': 'JetBrains Mono, monospace'}); dl.textContent = bar + ' mm'; dsvg.appendChild(dl);
$('rc-detail-cap').textContent = 'Two min traces ' + t + ' mm at ' + c + ' mm clearance, via pad ' + v + ' mm with ' + dr + ' mm drill, to scale.';
const items = Array.isArray(card.items) ? card.items : [];
const loads = Array.isArray(card.loads) ? card.loads : [];
$('rc-facts').innerHTML = items.map(i => '<li class="' + (i.flag ? 'flag' : '') + '">' + esc(i.label) + ' <b>' + esc(i.value) + ' ' + esc(i.unit) + '</b> <span>' + esc(i.source) + '</span></li>').join('')
+ loads.map(l => '<li class="' + (l.flag ? 'flag' : '') + '">load ' + esc(l.net) + ' <b>' + esc(l.amps) + ' A</b> <span>widest ' + esc(l.widestMm) + ' mm</span></li>').join('');
const flags = Array.isArray(card.flags) ? card.flags : [];
$('rc-flags').innerHTML = flags.map(f => '<li>' + esc(f) + '</li>').join('');
$('rc-note').textContent = String(card.note || 'Read-only: this card changes no gate.') + (card.rangeSource ? ' Range: ' + card.rangeSource + '.' : '');
}
let presentation = null;
let presentationRaw = null;
let presentationGeneration = 0;
// Optional artwork updates independently of measured workflow state.
// Keep the last good view across half-written JSON and failed image loads.
async function refreshPresentation(incoming) {
try {
let raw;
if (incoming !== undefined) raw = JSON.stringify(incoming);
else {
const res = await fetch('presentation.json', {cache: 'no-store', signal: AbortSignal.timeout(3000)});
if (!res.ok) return;
raw = await res.text();
}
if (raw === presentationRaw) return;
const p = JSON.parse(raw);
if (!p || typeof p !== 'object' || Array.isArray(p)) return;
let url = null;
if (p.image != null && p.image !== '') {
if (typeof p.image !== 'string') return;
url = new URL(p.image, location.href);
if (url.origin !== location.origin || !/\.(png|jpe?g|webp)$/i.test(url.pathname)) return;
}
const generation = ++presentationGeneration;
if (url) {
// Reload an overwritten filename when its presentation metadata changes.
url.searchParams.set('_presentation', Date.now() + '-' + generation);
const preview = new Image();
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('preview timeout')), 3000);
preview.onload = () => { clearTimeout(timeout); resolve(); };
preview.onerror = () => { clearTimeout(timeout); reject(new Error('preview unavailable')); };
preview.src = url.href;
});
if (generation !== presentationGeneration) return;
}
presentationRaw = raw;
presentation = p;
try { renderRulesCard(p.rulesCard); } catch (_) { /* the card is optional */ }
const img = $('board-image');
img.hidden = !url;
$('preview-empty').hidden = !!url;
$('board-caption').hidden = !url;
img.alt = String(p.alt || 'Saved board view');
$('board-caption').textContent = url ? String(p.caption || 'Saved board view · not a live render') : '';
if (url) img.src = url.href;
else img.removeAttribute('src');
if (model) render(model);
} catch (_) { /* Preserve the last successfully loaded presentation. */ }
}
$('contribute-prompt').addEventListener('click', async () => {
const prompt = $('contribute-prompt').title;
const feedback = $('copy-feedback');
try { await navigator.clipboard.writeText(prompt); feedback.textContent = 'Prompt copied'; }
catch { feedback.textContent = prompt; }
feedback.hidden = false;
setTimeout(() => { feedback.hidden = true; }, 8000);
});
// ---------------- state ----------------
let model = null; // normalized data
let lastRaw = '';
let stepKey = ''; // names joined, decides rebuild vs in-place update
const rows = new Map(); // name -> {li, ...}
let finishLi = null;
const phaseEls = new Map();
const seenLoops = new Set();
let firstRender = true;
let lastOkAt = 0, everOk = false;
let lastCurrent = null;
// ---------------- normalize ----------------
function normalize(d) {
if (!d || typeof d !== 'object' || !Array.isArray(d.steps) || !d.steps.length) return null;
const steps = [];
const seen = new Set();
d.steps.forEach((s, i) => {
if (!s || typeof s !== 'object') return;
let name = String(s.name || s.title || ('step-' + i));
while (seen.has(name)) name += '_';
seen.add(name);
steps.push({
name,
title: String(s.title || s.name || 'Step ' + (i + 1)),
does: String(s.does || ''),
phase: String(s.phase || ''),
status: STATUSES[s.status] ? s.status : 'todo',
visits: Math.max(0, parseInt(s.visits, 10) || 0),
minutes: Math.max(0, +s.minutes || 0),
});
});
if (!steps.length) return null;
let current = d.current && seen.has(String(d.current)) ? String(d.current) : null;
if (!current) {
const c = steps.find(s => s.status === 'current');
if (c) current = c.name;
}
steps.forEach(s => {
if (s.name === current) s.status = 'current';
else if (s.status === 'current') s.status = 'todo';
});
const idx = Object.fromEntries(steps.map((s, i) => [s.name, i]));
const loops = (Array.isArray(d.loops) ? d.loops : [])
.filter(l => l && idx[l.from] != null && idx[l.to] != null && idx[l.from] !== idx[l.to])
.map((l, i) => ({ from: String(l.from), to: String(l.to), why: String(l.why || ''), at: l.at || '', n: i + 1 }));
loops.forEach(l => { l.key = l.from + '>' + l.to + '@' + l.at + '#' + l.n; });
return {
title: String(d.title || d.label || d.runFolder || 'AI Flow run'),
board: String(d.board || ''),
aiThread: String(d.aiThread || ''),
runFolder: String(d.runFolder || ''),
runDir: String(d.runDir || ''),
engine: String(d.engine || ''),
finish: String(d.finish || 'Finished'),
updated: d.updated || '',
current, steps, loops, idx,
};
}
// ---------------- build ----------------
const ICON_CK = '<svg class="ck" viewBox="0 0 24 24"><path d="M5.5 12.5 10 17l8.5-9.5"/></svg>';
const ICON_RD = '<svg class="rd" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';
const ICON_CU = '<svg class="cu" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11"/></svg>';
const ICON_FL = '<svg class="fl" viewBox="0 0 24 24"><path d="M5 21V4"/><path d="M5 4.5c4.5-2 7.5 2 12 0v9c-4.5 2-7.5-2-12 0"/><path d="M9 4.2v9M13 5.4v9"/></svg>';
const ICON_BADGE = '<svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4.5h4.5"/></svg>';
function build(m) {
const ol = $('steps');
ol.textContent = '';
rows.clear();
m.steps.forEach((s, i) => {
const li = document.createElement('li');
li.className = 'step todo';
li.dataset.name = s.name;
li.dataset.tipkey = 'step:' + s.name;
li.innerHTML =
'<div class="rowbg"></div>' +
'<div class="node">' + ICON_CK + ICON_RD + ICON_CU + '</div>' +
'<div class="label"><div class="t"><span class="n">' + (i + 1) + '</span><span class="nm"></span><span class="badge off">' + ICON_BADGE + '<span class="v"></span></span></div><div class="does"></div></div>' +
'<div class="meta"></div>';
ol.appendChild(li);
rows.set(s.name, {
li, node: li.querySelector('.node'), nm: li.querySelector('.nm'), badge: li.querySelector('.badge'),
v: li.querySelector('.badge .v'), does: li.querySelector('.does'), meta: li.querySelector('.meta'), status: null,
});
});
finishLi = document.createElement('li');
finishLi.className = 'step finish';
finishLi.dataset.tipkey = 'finish';
finishLi.innerHTML = '<div class="rowbg"></div><div class="node">' + ICON_FL + '</div><div class="label"><div class="t"><span class="n"></span><span class="nm"><span class="k">Finish line: </span><span class="fx"></span></span></div></div><div class="meta"></div>';
ol.appendChild(finishLi);
const ph = $('phases');
ph.textContent = '';
phaseEls.clear();
groupsOf(m).forEach(g => {
const el = document.createElement('div');
el.className = 'phase';
el.dataset.tipkey = 'phase:' + g.key;
el.innerHTML = '<div class="bar"></div><div class="nm"></div><div class="ct"></div>';
el.querySelector('.nm').textContent = g.phase || 'Steps';
ph.appendChild(el);
phaseEls.set(g.key, el);
});
}
function groupsOf(m) {
const out = [];
m.steps.forEach((s, i) => {
const last = out[out.length - 1];
if (last && last.phase === s.phase) last.end = i;
else out.push({ phase: s.phase, start: i, end: i, key: s.phase + '#' + i });
});
return out;
}
// ---------------- render ----------------
function render(m) {
const key = m.steps.map(s => s.name + '|' + s.phase).join(',');
if (key !== stepKey) { build(m); stepKey = key; firstRender = true; seenLoops.clear(); }
const total = m.steps.length;
const done = m.steps.filter(s => s.status === 'done').length;
const redo = m.steps.filter(s => s.status === 'redo').length;
const allDone = done === total;
const curIdx = m.current ? m.idx[m.current] : -1;
const posIdx = curIdx >= 0 ? curIdx : (allDone ? total - 1 : Math.max(0, m.steps.findIndex(s => s.status !== 'done')));
const toGo = m.steps.filter(s => s.status === 'todo' || s.status === 'redo').length;
const minutes = m.steps.reduce((a, s) => a + s.minutes, 0);
const active = m.steps[posIdx];
const early = ['intake','rules','design','test-plan','sourcing','components','libraries','library','schematic','simulation','models'];
const context = {
intake: ['Start with the goal', 'Define what to build, the constraints, and what success looks like.'],
rules: ['Set the boundaries', 'Manufacturing limits and project requirements guide the design.'],
design: ['Shape the circuit', 'Circuit decisions and calculations come before board layout.'],
sourcing: ['Choose the right parts', 'Compare availability, cost, and component evidence.'],
schematic: ['Connect the design', 'The schematic captures how the circuit works.'],
simulation: ['Test the idea', 'Review simulation evidence against the requirements.']
};
const copy = context[active.name] || [early.includes(active.name) ? 'Build the design foundation' : 'Follow the engineering evidence', active.does || 'Recorded work and checks determine the next step.'];
$('project-phase').textContent = presentation?.title || copy[0];
$('project-context').textContent = presentation?.summary || copy[1];
// header
document.title = (presentation?.projectTitle || m.title) + ' \u00b7 AI Flow';
$('title').textContent = presentation?.projectTitle || m.title;
$('title').title = presentation?.projectTitle || m.title;
runline(m);
$('engine').style.display = m.engine ? '' : 'none';
$('engine').querySelector('span').textContent = m.engine;
$('finish').textContent = m.finish;
setText('s-step', allDone && curIdx < 0 ? total : posIdx + 1);
document.querySelector('.stat.stepno').classList.toggle('won', allDone && curIdx < 0);
setText('s-total', total);
setText('s-togo', toGo);
setText('s-min', fmtMin(minutes));
setText('s-loops', m.loops.length);
$('s-loops-l').textContent = m.loops.length === 1 ? 'loop back' : 'loop backs';
$('s-loops-w').classList.toggle('none', !m.loops.length);
const C = 2 * Math.PI * 36;
const fDone = done / total, fRedo = redo / total;
$('ring-done').style.strokeDasharray = (C * fDone) + ' ' + C;
$('ring-redo').style.strokeDasharray = (C * fRedo) + ' ' + C;
$('ring-redo').style.strokeDashoffset = -(C * fDone);
$('pct').firstChild.nodeValue = Math.round(fDone * 100);
$('when').textContent = fmtClockS(m.updated);
$('recent-work').innerHTML = m.steps.filter(s => s.status === 'done').slice(-3).map(s => '<li>' + esc(s.title) + '</li>').join('') || '<li style="color:var(--text-2);background:transparent">No completed steps recorded yet</li>';
// rows
m.steps.forEach((s, i) => {
const r = rows.get(s.name);
const prev = r.status;
const cls = s.status === 'current' ? 'cur' : s.status;
if (prev !== s.status) {
r.li.className = 'step ' + cls;
if (!firstRender && s.status === 'done' && prev && prev !== 'done') flash(r.li, 'just-done', 1000);
if (!firstRender && s.status === 'redo' && prev && prev !== 'redo') flash(r.li, 'just-redo', 1100);
r.status = s.status;
}
if (r.nm.textContent !== s.title) r.nm.textContent = s.title;
if (r.does.textContent !== s.does) r.does.textContent = s.does;
const showBadge = s.visits > 1;
r.badge.classList.toggle('off', !showBadge);
r.v.textContent = 'x' + s.visits;
r.badge.setAttribute('aria-label', 'Visited ' + s.visits + ' times');
let meta = '';
if (s.status === 'redo') meta = '<span class="redo-tag">rework</span>';
else if (s.minutes > 0) meta = fmtMin(s.minutes) + ' min';
if (r.meta._v !== meta) { r.meta.innerHTML = meta; r.meta._v = meta; }
});
finishLi.querySelector('.fx').textContent = m.finish;
finishLi.classList.toggle('won', allDone);
// phases
groupsOf(m).forEach(g => {
const el = phaseEls.get(g.key);
if (!el) return;
const ss = m.steps.slice(g.start, g.end + 1);
const nd = ss.filter(s => s.status === 'done').length;
el.classList.toggle('active', ss.some(s => s.status === 'current'));
el.classList.toggle('complete', nd === ss.length);
el.classList.toggle('rework', !ss.some(s => s.status === 'current') && ss.some(s => s.status === 'redo'));
el.querySelector('.ct').textContent = nd + ' of ' + ss.length;
});
// latest loop callout (narrow) and loop list (wide)
const latest = m.loops[m.loops.length - 1];
const wrap = $('callout-wrap');
if (latest) {
const fresh = !seenLoops.has(latest.key) && !firstRender;
$('co-n').textContent = 'Loop back ' + latest.n + ':';
$('co-path').textContent = titleOf(m, latest.from) + ' sent the AI back to ' + titleOf(m, latest.to);
$('co-t').textContent = fmtClock(latest.at);
$('co-why').textContent = latest.why;
wrap.classList.add('on');
if (fresh) flash($('callout'), 'fresh', 1500);
} else wrap.classList.remove('on');
const ll = $('looplist');
ll.innerHTML = m.loops.length ? m.loops.slice().reverse().slice(0, 4).map(l =>
'<li class="' + (l === latest ? 'latest' : '') + '" data-tipkey="loop:' + esc(l.key) + '" data-loop="' + esc(l.key) + '">' +
'<span class="num">' + l.n + '</span>' +
'<span class="path">' + esc(titleOf(m, l.from)) + ' <svg class="i"><use href="#i-back"/></svg> back to ' + esc(titleOf(m, l.to)) + '</span>' +
'<span class="tm">' + esc(fmtClock(l.at)) + '</span>' +
'<span class="why">' + esc(l.why) + '</span></li>').join('')
: '<li><span class="empty">No loop backs yet. When a later step finds a problem, the AI goes back to an earlier step and it shows up here.</span></li>';
// now card (wide)
const cs = m.steps[posIdx];
$('now-n').textContent = allDone && curIdx < 0 ? '' : String(posIdx + 1).padStart(2, '0');
$('now-title').textContent = allDone && curIdx < 0 ? 'Finished: ' + m.finish : cs.title;
$('now-does').textContent = allDone && curIdx < 0 ? 'Every step is done.' : cs.does;
$('now-facts').innerHTML = allDone && curIdx < 0 ? '' :
'<span>Phase <b>' + esc(cs.phase || '-') + '</b></span>' +
'<span>Visit <b>' + Math.max(1, cs.visits) + '</b></span>' +
'<span>Time so far <b>' + fmtMin(cs.minutes) + ' min</b></span>';
if (m.current !== lastCurrent) {
const changed = lastCurrent !== null || !firstRender;
lastCurrent = m.current;
if (changed && !firstRender) setTimeout(() => scrollCurrentIntoView(true), 650);
}
if (firstRender) document.body.classList.add('notrans');
layout();
if (firstRender) { drawOverlay(); requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('notrans'))); }
animateOverlay(firstRender ? 400 : 1100);
m.loops.forEach(l => seenLoops.add(l.key));
firstRender = false;
}
// the run line under the title: board file, lane, AI thread, run folder. It never wraps: the
// thread ellipsizes, and when the line is still too wide the run folder, then the lane, then the
// board are dropped (the AI thread always stays).
function runline(m) {
const items = [['rl-board', m.board ? 'Board ' + m.board : ''], ['rl-lane', m.engine], ['rl-thread', m.aiThread ? 'AI thread: ' + m.aiThread : ''], ['rl-folder', m.runFolder ? 'Run ' + m.runFolder : '']];
items.forEach(([id, text]) => { const el = $(id); if (el.textContent !== text) el.textContent = text; el.hidden = !text; });
$('runline').dataset.detail = [m.board, m.engine, m.aiThread && 'AI thread ' + m.aiThread, m.runDir].filter(Boolean).join(' \u00b7 ');
fitRunline();
}
function fitRunline() {
const line = $('runline');
['rl-board', 'rl-lane', 'rl-folder'].forEach(id => { $(id).hidden = !$(id).textContent; });
const firstShown = () => { let f = true; line.querySelectorAll('span').forEach(el => { el.classList.toggle('first', f && !el.hidden); if (!el.hidden) f = false; }); };
firstShown();
for (const id of ['rl-folder', 'rl-lane', 'rl-board']) {
if (line.scrollWidth <= line.clientWidth + 1) break;
$(id).hidden = true;
firstShown();
}
}
window.addEventListener('resize', fitRunline);
function titleOf(m, name) { const i = m.idx[name]; return i == null ? name : m.steps[i].title; }
function setText(id, v) { const el = $(id); const s = String(v); if (el.textContent !== s) el.textContent = s; }
function flash(el, cls, ms) { el.classList.remove(cls); void el.offsetWidth; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), ms); }
// ---------------- layout ----------------
let wide = false, laneH = 42, laneW = 26;
function lanesFor(m) {
// interval colouring: shortest loops hug the spine, longer ones go outside them
const items = m.loops.map(l => {
const a = m.idx[l.to], b = m.idx[l.from];
return { l, lo: Math.min(a, b), hi: Math.max(a, b) };
}).sort((x, y) => (x.hi - x.lo) - (y.hi - y.lo) || x.lo - y.lo);
const placed = [];
items.forEach(it => {
let lane = 0;
const taken = new Set(placed.filter(p => !(p.hi < it.lo || p.lo > it.hi)).map(p => p.lane));
while (taken.has(lane)) lane++;
it.lane = lane;
placed.push(it);
});
const map = new Map(placed.map(p => [p.l.key, p.lane]));
const count = placed.length ? Math.max(...placed.map(p => p.lane)) + 1 : 0;
return { map, count };
}
function layout() {
if (!model) return;
wide = innerWidth > WIDE_AT;
document.body.classList.toggle('wide', wide);
const lanes = lanesFor(model);
const root = document.documentElement.style;
if (wide) {
const phaseBand = 40;
const nl = Math.max(2, lanes.count);
const setZone = lh => { laneH = lh; root.setProperty('--htop', (phaseBand + 40 + nl * lh) + 'px'); };
setZone(42);
// spend spare height on taller loop arcs so the page is balanced on a big screen
const spare = innerHeight - ($('lower').getBoundingClientRect().bottom + scrollY) - 28;
if (spare > 0) setZone(Math.min(84, 42 + spare / nl));
const arcZone = 40 + nl * laneH;
root.setProperty('--arcZone', arcZone + 'px');
$('steps').style.gridTemplateColumns = model.steps.map(s => s.name === model.current ? '1.5fr' : '1fr').join(' ') + ' 1.3fr';
} else {
$('steps').style.gridTemplateColumns = '';
// fixed gutter so a new loop never shifts the list sideways; lanes tighten instead
laneW = Math.min(26, 66 / Math.max(1, lanes.count));
root.setProperty('--gut', '96px');
fitRows();
}
}
function fitRows() {
// make every step fit the pane height when possible, without going below a readable row
const stage = $('stage');
const top = stage.getBoundingClientRect().top + scrollY;
const n = model.steps.length + 1;
const curH = innerWidth <= 620 ? 96 : 86;
const avail = innerHeight - top - 14;
let row = Math.floor((avail - curH) / (n - 1));
row = Math.max(26, Math.min(44, row));
const rs = document.documentElement.style;
if (rs.getPropertyValue('--row') !== row + 'px' || rs.getPropertyValue('--row-cur') !== curH + 'px') {
rs.setProperty('--row', row + 'px');
rs.setProperty('--row-cur', curH + 'px');
animUntil = Math.max(animUntil, performance.now() + 750);
}
}
// positions of every node centre, relative to the stage box
function measure() {
const st = $('stage').getBoundingClientRect();
const pts = new Map();
rows.forEach((r, name) => {
const b = r.node.getBoundingClientRect();
pts.set(name, { x: b.left + b.width / 2 - st.left, y: b.top + b.height / 2 - st.top, r: b.width / 2, li: r.li.getBoundingClientRect() });
});
const fb = finishLi.querySelector('.node').getBoundingClientRect();
pts.set('__finish', { x: fb.left + fb.width / 2 - st.left, y: fb.top + fb.height / 2 - st.top, r: fb.width / 2, li: finishLi.getBoundingClientRect() });
return { st, pts };
}
let animUntil = 0, rafOn = false;
function animateOverlay(ms) {
animUntil = Math.max(animUntil, performance.now() + ms);
if (!rafOn) { rafOn = true; requestAnimationFrame(tick); }
}
function tick() {
if (model && !wide) fitRows();
drawOverlay();
if (performance.now() < animUntil) requestAnimationFrame(tick);
else rafOn = false;
}
function drawOverlay() {
if (!model) return;
const { st, pts } = measure();
const m = model;
const first = pts.get(m.steps[0].name), fin = pts.get('__finish');
const spine = $('spine'), fill = $('spine-fill');
const curName = m.current || (m.steps.every(s => s.status === 'done') ? '__finish' : null);
const cur = curName ? pts.get(curName) : null;
// progress reaches the last contiguous done step, or the current step
let reachName = curName;
if (!reachName) {
let k = -1; m.steps.forEach((s, i) => { if (s.status === 'done' && k === i - 1) k = i; });
reachName = k >= 0 ? m.steps[k].name : m.steps[0].name;
}
const reach = pts.get(reachName);
if (wide) {
setBox(spine, { left: first.x, top: first.y - 1.5, width: fin.x - first.x, height: 3 });
setBox(fill, { left: first.x, top: first.y - 1.5, width: Math.max(0, reach.x - first.x), height: 3 });
fill.style.background = 'linear-gradient(90deg, rgba(0, 184, 177,.85), var(--accent))';
} else {
setBox(spine, { left: first.x - 1.5, top: first.y, width: 3, height: fin.y - first.y });
setBox(fill, { left: first.x - 1.5, top: first.y, width: 3, height: Math.max(0, reach.y - first.y) });
fill.style.background = '';
}
const halo = $('halo');
if (cur && curName !== '__finish') {
halo.classList.remove('hide');
const t = 'translate(' + cur.x.toFixed(1) + 'px,' + cur.y.toFixed(1) + 'px)';
if (halo._t !== t) { halo.style.transform = t; halo._t = t; }
} else halo.classList.add('hide');
drawPhases(pts, st);
drawArcs(pts);
}
function setBox(el, b) {
for (const k in b) {
const v = b[k].toFixed(1) + 'px';
if (el.style[k] !== v) el.style[k] = v;
}
}
function drawPhases(pts, st) {
groupsOf(model).forEach(g => {
const el = phaseEls.get(g.key);
if (!el) return;
const a = pts.get(model.steps[g.start].name), b = pts.get(model.steps[g.end].name);
const bar = el.querySelector('.bar'), nm = el.querySelector('.nm'), ct = el.querySelector('.ct');
if (wide) {
const half = (a.li.width) / 2;
const left = a.x - half + 10, right = b.x + half - 10;
setBox(el, { left, top: 0, width: right - left, height: 30 });
Object.assign(bar.style, { left: '0px', right: '0px', top: '24px', height: '3px', width: '', bottom: '' });
Object.assign(nm.style, { left: '2px', top: '0px', right: '', fontSize: '' });
Object.assign(ct.style, { right: '2px', top: '2px', left: '' });
ct.style.display = '';
ct.style.display = (nm.offsetWidth + ct.offsetWidth + 18) < (right - left) ? '' : 'none';
} else {
const top = a.li.top - st.top + 5, bottom = b.li.bottom - st.top - 5;
const left = a.li.right - st.left - parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--phase-col')) + 8;
setBox(el, { left, top, width: 100, height: Math.max(10, bottom - top) });
Object.assign(bar.style, { left: '0px', top: '0px', bottom: '0px', width: '3px', height: '', right: '' });
Object.assign(nm.style, { left: '13px', top: '-2px', right: '' });
Object.assign(ct.style, { left: '13px', top: '17px', right: '' });
ct.style.display = (bottom - top) > 44 ? '' : 'none';
}
});
}
function placePill(l, bx, by, isNew) {
const pill = $('arcpill');
if (!wide) return;
if (pill._k !== l.key) {
pill._k = l.key;
pill.dataset.tipkey = 'loop:' + l.key;
pill.querySelector('.num').textContent = l.n;
pill.querySelector('.tx').innerHTML = '<b>' + esc(titleOf(model, l.from)) + ' sent the AI back to ' + esc(titleOf(model, l.to)) + ':</b> ' + esc(l.why);
if (isNew && !reduced.matches) flash(pill, 'new', 1700);
}
const W = $('stage').clientWidth, w = pill.offsetWidth, h = pill.offsetHeight;
const x = Math.max(8, Math.min(W - w - 8, bx - w / 2));
setBox(pill, { left: x, top: by - h / 2 });
}
function drawArcs(pts) {
const svg = $('overlay');
$('arcpill').classList.toggle('on', wide && model.loops.length > 0);
const m = model;
const lanes = lanesFor(m);
const latest = m.loops[m.loops.length - 1];
const want = new Set(m.loops.map(l => l.key));
svg.querySelectorAll('g.arcgrp').forEach(g => { if (!want.has(g.dataset.key)) g.remove(); });
m.loops.forEach(l => {
let g = svg.querySelector('g.arcgrp[data-key="' + CSS.escape(l.key) + '"]');
const isNew = !g && !firstRender && !seenLoops.has(l.key);
if (!g) {
g = svgEl('g', { class: 'arcgrp', 'data-key': l.key, 'data-tipkey': 'loop:' + l.key });
g.appendChild(svgEl('path', { class: 'arc' + (isNew && !reduced.matches ? ' new' : ''), pathLength: '1' }));
g.appendChild(svgEl('path', { class: 'arrow' + (isNew && !reduced.matches ? ' new' : '') }));
g.appendChild(svgEl('path', { class: 'arc-hit', 'data-tipkey': 'loop:' + l.key }));
const b = svgEl('g', { class: 'loopbadge' + (isNew && !reduced.matches ? ' new' : ''), 'data-tipkey': 'loop:' + l.key });
b.appendChild(svgEl('circle', { r: '10' }));
const t = svgEl('text', {}); t.textContent = l.n; b.appendChild(t);
g.appendChild(b);
svg.appendChild(g);
if (isNew) setTimeout(() => g.querySelectorAll('.new').forEach(e => e.classList.remove('new')), 1700);
}
const isLatest = l === latest;
g.querySelector('.arc').classList.toggle('latest', isLatest);
g.querySelector('.arrow').classList.toggle('latest', isLatest);
g.querySelector('.loopbadge').classList.toggle('latest', isLatest);
if (isLatest) svg.appendChild(g); // latest on top
const A = pts.get(l.from), B = pts.get(l.to); // A later step, B earlier step it sent the AI to
const lane = lanes.map.get(l.key) || 0;
let d, arrow, bx, by;
if (wide) {
const y0 = A.y - A.r - 4, y1 = B.y - B.r - 9;
const peak = 26 + (lane + 1) * laneH;
const k = peak / 0.75;
d = 'M' + A.x + ' ' + y0 + ' C' + A.x + ' ' + (y0 - k) + ' ' + B.x + ' ' + (y1 - k) + ' ' + B.x + ' ' + y1;
arrow = 'M' + (B.x - 6) + ' ' + (y1 - 8) + ' L' + B.x + ' ' + (y1 + 1) + ' L' + (B.x + 6) + ' ' + (y1 - 8) + 'Z';
bx = (A.x + B.x) / 2; by = (y0 + y1) / 2 - peak;
} else {
const x0 = A.x - A.r - 5, x1 = B.x - B.r - 10;
const depth = 20 + (lane + 1) * laneW;
const k = depth / 0.75;
d = 'M' + x0 + ' ' + A.y + ' C' + (x0 - k) + ' ' + A.y + ' ' + (x1 - k) + ' ' + B.y + ' ' + x1 + ' ' + B.y;
arrow = 'M' + (x1 - 8) + ' ' + (B.y - 6) + ' L' + (x1 + 1) + ' ' + B.y + ' L' + (x1 - 8) + ' ' + (B.y + 6) + 'Z';
bx = (x0 + x1) / 2 - depth; by = (A.y + B.y) / 2;
}
const arc = g.querySelector('.arc');
if (arc.getAttribute('d') !== d) { arc.setAttribute('d', d); g.querySelector('.arc-hit').setAttribute('d', d); g.querySelector('.arrow').setAttribute('d', arrow); }
g.querySelector('.loopbadge').setAttribute('transform', 'translate(' + bx.toFixed(1) + ' ' + by.toFixed(1) + ')');
if (isLatest) placePill(l, bx, by, isNew);
});
}
function scrollCurrentIntoView(smooth) {
if (!model || wide) return;
const name = model.current;
if (!name || !rows.has(name)) return;
const r = rows.get(name).li.getBoundingClientRect();
if (r.top < 0 || r.bottom > innerHeight) {
window.scrollTo({ top: Math.max(0, scrollY + r.top - innerHeight / 2 + r.height / 2), behavior: smooth && !reduced.matches ? 'smooth' : 'auto' });
}
}
// ---------------- tooltips ----------------
const tip = $('tip');
let tipTimer = 0, tipFor = null, cx = 0, cy = 0;
function tipHtml(key) {
const m = model;
if (!m) return null;
if (key.startsWith('step:')) {
const s = m.steps[m.idx[key.slice(5)]];
if (!s) return null;
const i = m.idx[s.name];
const sentBack = m.loops.filter(l => l.to === s.name);
const sentFrom = m.loops.filter(l => l.from === s.name);
let h = '<div class="tt">' + (i + 1) + '. ' + esc(s.title) + '<span class="s ' + s.status + '">' + STATUS_LABEL[s.status] + '</span></div>';
if (s.does) h += '<div>' + esc(s.does) + '</div>';
sentFrom.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Sent the AI back to ' + esc(titleOf(m, l.to)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });
sentBack.forEach(l => { h += '<div style="margin-top:6px;color:var(--purple)">Reworked after ' + esc(titleOf(m, l.from)) + ': <span style="color:var(--text-body)">' + esc(l.why) + '</span></div>'; });
h += '<div class="ft">' + esc(s.phase || 'No phase') + ' · visited ' + s.visits + (s.visits === 1 ? ' time' : ' times') + ' · ' + fmtMin(s.minutes) + ' AI min</div>';
return { h };
}
if (key.startsWith('loop:')) {
const l = m.loops.find(x => x.key === key.slice(5));
if (!l) return null;
return { loopy: true, h: '<div class="tt">Loop back ' + l.n + '<span class="s redo">' + esc(fmtClock(l.at)) + '</span></div>' +
'<div><b style="color:var(--text)">' + esc(titleOf(m, l.from)) + '</b> found a problem and sent the AI back to <b style="color:var(--text)">' + esc(titleOf(m, l.to)) + '</b>.</div>' +
'<div style="margin-top:6px">' + esc(l.why) + '</div>' +
'<div class="ft">Every step from ' + esc(titleOf(m, l.to)) + ' on gets redone.</div>' };
}
if (key.startsWith('phase:')) {
const g = groupsOf(m).find(x => x.key === key.slice(6));
if (!g) return null;
const ss = m.steps.slice(g.start, g.end + 1);
return { h: '<div class="tt">' + esc(g.phase || 'Steps') + ' phase</div><div>' + ss.map(s => esc(s.title)).join(', ') + '</div><div class="ft">' + ss.filter(s => s.status === 'done').length + ' of ' + ss.length + ' done</div>' };
}
const fixed = {
brand: '<div class="tt">AI Flow Timeline</div><div>Live map of this AI Flow run: every step, where the AI is now, and every time it looped back to rework.</div><div class="ft">Engine ' + esc(m.engine || 'unknown') + ' \u00b7 reads ' + esc(SRC) + ' every 1 s</div>',
runline: '<div class="tt">This run</div><div>' + esc($('runline').dataset.detail || 'No board attached yet') + '</div><div class="ft">The AI thread is the conversation that last drove this run.</div>',
engine: '<div class="tt">CAD engine</div><div>The design tool the AI is driving for this board.</div>',
live: '<div class="tt">Live</div><div>This page reads timeline.json every second. The time is the last update the flow wrote.</div>',
finish: '<div class="tt">Finish line</div><div>The flow is done when this is true: ' + esc(m.finish) + '.</div>',
stepno: '<div class="tt">Where we are</div><div>The step the AI is working on, counted along the timeline.</div>',
togo: '<div class="tt">Steps to go</div><div>Steps not done yet, including steps a loop back sent us to redo.</div>',
minutes: '<div class="tt">AI minutes</div><div>Total time the AI has spent across every step and every visit.</div>',
loops: '<div class="tt">Loop backs</div><div>Times a later step found a problem and sent the AI back to rework an earlier step. This is normal: it is how the flow converges on a good board.</div>',
ring: '<div class="tt">Progress</div><div>Share of steps done. The purple part is work a loop back reopened.</div>',
callout: '<div class="tt">Latest loop back</div><div>The most recent time the AI went back to rework an earlier step, and why.</div>',
};
return fixed[key] ? { h: fixed[key], loopy: key === 'callout' || key === 'loops' } : null;
}
function placeTip() {
const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight, M = 8, O = 16;
const w = tip.offsetWidth, h = tip.offsetHeight;
let x = cx + O, y = cy + O;
if (x + w > W - M) x = cx - w - O; // flip left
if (y + h > H - M) y = cy - h - O; // flip above
x = Math.max(M, Math.min(x, W - w - M));
y = Math.max(M, Math.min(y, H - h - M));
tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
}
function showTip(key) {
const t = tipHtml(key);
if (!t) return hideTip();
tip.innerHTML = t.h;
tip.classList.toggle('loopy', !!t.loopy);
tip.classList.add('on');
placeTip();
requestAnimationFrame(() => tip.classList.add('shown'));
}
function hideTip() { clearTimeout(tipTimer); tipFor = null; tip.classList.remove('on', 'shown'); highlightLoop(null); }
function highlightLoop(k) {
document.querySelectorAll('g.arcgrp').forEach(g => {
g.classList.toggle('hl', !!k && g.dataset.key === k);
g.classList.toggle('dim', !!k && g.dataset.key !== k);
});
}
function keyFrom(target) {
const el = target && target.closest ? target.closest('[data-tipkey]') : null;
return el ? el.getAttribute('data-tipkey') : null;
}
function loopKeyFor(k) {
if (!k || !model) return null;
if (k.startsWith('loop:')) return k.slice(5);
if (k === 'callout' && model.loops.length) return model.loops[model.loops.length - 1].key;
return null;
}
document.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return;
cx = e.clientX; cy = e.clientY;
const k = keyFrom(e.target);
if (k !== tipFor) {
hideTip();
tipFor = k;
if (k) {
highlightLoop(loopKeyFor(k));
tipTimer = setTimeout(() => showTip(k), 600);
}
} else if (tip.classList.contains('on')) placeTip();
}, { passive: true });
document.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch' && e.pointerType !== 'pen') return;
cx = e.clientX; cy = e.clientY;
const k = keyFrom(e.target);
if (k && k !== tipFor) { hideTip(); tipFor = k; highlightLoop(loopKeyFor(k)); showTip(k); }
else hideTip();
});
document.addEventListener('pointerleave', hideTip);
addEventListener('scroll', () => { if (tipFor) hideTip(); }, { passive: true });
// One live page per run. CLI writes are delivered without navigation or reload.
let liveChannel = 'connecting';
let livePresentation = null;
function setLive(state) {
const el = $('live');
el.classList.toggle('stale', state === 'stale');
el.classList.toggle('idle', state === 'idle');
el.querySelector('.lbl').textContent = state === 'stale' ? 'Reconnecting' : (state === 'idle' ? 'Waiting' : 'Live');
}
function receiveTimeline(data) {
const raw = JSON.stringify(data);
const m = normalize(data);
if (!m) return;
lastOkAt = Date.now();
if (raw !== lastRaw) {
lastRaw = raw; model = m; everOk = true;
document.body.classList.remove('waiting-on'); render(m);
}
setLive('live');
}
async function poll() {
// Compatibility only for static exports/older servers; SSE owns normal updates.
if (liveChannel !== 'sse') {
try {
const sep = SRC.includes('?') ? '&' : '?';
const res = await fetch(SRC + sep + '_=' + Date.now(), {cache:'no-store',signal:AbortSignal.timeout(3000)});
if (!res.ok) throw new Error('HTTP ' + res.status);
const snapshot = await res.json();
if (liveChannel !== 'sse') {
receiveTimeline(snapshot);
await refreshPresentation();
}
} catch (_) { setLive(everOk ? 'stale' : 'idle'); }
}
if (liveChannel === 'sse' && livePresentation) await refreshPresentation(livePresentation);
setTimeout(poll, 3000);
}
if (typeof EventSource !== 'undefined' && SRC === 'timeline.json') {
const channel = new EventSource('events');
channel.addEventListener('state', event => {
try {
const state = JSON.parse(event.data);
receiveTimeline(state.timeline);
if (state.presentation) { livePresentation = state.presentation; refreshPresentation(livePresentation); }
liveChannel = 'sse';
} catch (_) { setLive('stale'); }
});
channel.onerror = () => { liveChannel = 'reconnecting'; setLive('stale'); };
addEventListener('pagehide', () => channel.close(), {once:true});
}
let rz = 0;
addEventListener('resize', () => {
cancelAnimationFrame(rz);
rz = requestAnimationFrame(() => { layout(); animateOverlay(700); });
});
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { if (model) { layout(); animateOverlay(300); } });
// test and driving hook: window.flowTimeline.state() returns what is on screen
window.flowTimeline = {
state: () => model && ({ current: model.current, liveChannel, wide, steps: model.steps.length, loops: model.loops.length }),
tipCheck: () => { const r = tip.getBoundingClientRect(); return { on: tip.classList.contains('on'), left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight }; },
showTip: (key, x, y) => { cx = x; cy = y; tipFor = key; showTip(key); },
};
poll();
})();
</script>
</body>
</html>