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">&middot;</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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>