Files
Harbor/harbor/web/app.html
T
KeelrLeek d5eb82261e Logbook progress map in the style of decomp.dev: "<project> is NN.NN% decompiled", a layered Code bar (exact solid, verified lighter, readable hatched, with per-segment tooltips), a Data bar (named globals of the data addresses the code touches), Previous / Next / Latest through the history reports, a Units dropdown and filter (text, <70%, >10kb), and a canvas treemap of units (original source files, else 64 KB blocks) that drills into functions
- GET /api/logbook/progress-map: one compact document, cached until an input changes (main's HEAD, the Logbook symbol table and statistics, match/emulator/library results, facts, names, source files, globals, deferral notes); ?have=<version> answers {"same": true}
- definitions follow the Logbook's progress.py (pc102.csv category game, status decompiled or later), so the headline equals game_bytes_decompiled_percent; engine, library and unsorted code are shown but not counted
- history points now record the commit and the game-code bytes
- the page lays out once per load, filter or size change, hit-tests hover on the computed rectangles, builds the 600-entry units list only when opened, and polls once a minute only while the Logbook is visible
2026-10-06 15:46:42 -05:00

1629 lines
167 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Harbor</title>
<script>try{var r=document.documentElement,t=JSON.parse(localStorage.getItem('harbor.theme')),p=JSON.parse(localStorage.getItem('harbor.palette'));if(t==='light'||t==='dark')r.dataset.theme=t;if(typeof p==='string'&&/^[a-z0-9-]{1,30}$/.test(p)&&p!=='harbor')r.dataset.palette=p}catch(e){}</script>
<link rel="preload" href="static/InterVariable.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="static/harbor.css">
<link rel="stylesheet" href="static/themes.css">
<style>
body { overflow: hidden; }
.shell { display: grid; grid-template-columns: 252px minmax(0, 1fr); height: 100dvh; }
/* sidebar */
.side {
display: flex; flex-direction: column; gap: 22px; padding: 18px 12px 14px; overflow-y: auto; z-index: 20;
border-right: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 11px; padding: 2px 8px; }
.logo {
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0; display: grid; place-items: center; color: var(--on-action);
background: linear-gradient(145deg, color-mix(in srgb, var(--action) 70%, #fff), var(--action) 55%, color-mix(in srgb, var(--action) 78%, #000));
box-shadow: 0 8px 18px -8px color-mix(in srgb, var(--action) 75%, transparent), inset 0 1px 0 rgba(255, 255, 255, .3);
}
.logo svg.i { width: 20px; height: 20px; stroke-width: 2; }
.brand-name { font: 700 14.5px/1.25 var(--font-display); letter-spacing: -.2px; overflow-wrap: anywhere; }
.brand-sub { font-size: 11.5px; color: var(--muted); }
.side select { width: 100%; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-label { font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; font-weight: 700; color: var(--faint); padding: 0 10px 6px; }
.nav-btn {
display: flex; align-items: center; gap: 11px; width: 100%; height: 36px; padding: 0 10px; border: 0; border-radius: 9px;
background: transparent; color: var(--text-2); font-size: 13px; font-weight: 600; text-align: left; transition: background .15s, color .15s;
}
.nav-btn:hover { background: var(--surface-3); color: var(--text); text-decoration: none; }
.nav-btn svg.i { color: var(--muted); }
.nav-btn[aria-current="page"] { background: var(--accent-soft); color: var(--accent-ink); }
.nav-btn[aria-current="page"] svg.i { color: var(--accent); }
.nav-btn .count-badge, .nav-btn .end { margin-left: auto; }
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 12px; padding: 14px 6px 0; border-top: 1px solid var(--line); }
.side-foot .row { justify-content: space-between; flex-wrap: nowrap; }
.theme span { display: grid; place-items: center; padding: 5px 9px; }
/* main column */
.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.topbar {
display: flex; align-items: center; gap: 14px; padding: 18px 32px 16px; flex-shrink: 0;
border-bottom: 1px solid var(--line); background: var(--header);
}
.topbar h1 { margin: 0; font: 700 21px/1.2 var(--font-display); letter-spacing: -.5px; }
.topbar .sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
#menuBtn, .scrim { display: none; }
.content { flex: 1; min-height: 0; overflow-y: auto; padding: 24px 32px 56px; }
.view { display: none; max-width: 1380px; margin: 0 auto; gap: 20px; }
.view.on { display: grid; }
.lb { --lb-game: var(--blue); --lb-engine: var(--violet); --lb-platform: var(--amber); --lb-crt: var(--muted); --lb-unsorted: var(--line-2); --lb-done: var(--green); }
/* progress map (decomp.dev style) */
.lb { --pm-exact: var(--green); --pm-verified: color-mix(in srgb, var(--green) 62%, var(--surface)); --pm-readable: color-mix(in srgb, color-mix(in srgb, var(--green) 52%, #d8c22e) 68%, var(--surface));
--pm-raw: var(--line-2); --pm-deferred: color-mix(in srgb, var(--line-2) 62%, var(--amber)); --pm-unsorted: color-mix(in srgb, var(--line-2) 50%, var(--surface-3));
--pm-engine: color-mix(in srgb, var(--blue) 30%, var(--line-2)); --pm-library: color-mix(in srgb, var(--blue) 22%, var(--line-2)); }
.pm-title { margin: 0 0 14px; font: 700 22px/1.25 var(--font-display); letter-spacing: -.4px; } .pm-title b { color: var(--green); font-variant-numeric: tabular-nums; }
.pm-bars { display: grid; gap: 12px; }
.pm-bar-l { display: flex; align-items: baseline; gap: 6px; font-size: 13px; margin-bottom: 5px; flex-wrap: wrap; } .pm-bar-l .grow { flex: 1; } .pm-bar-l .mono { font: 600 12.5px var(--mono); font-variant-numeric: tabular-nums; }
.pm-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
.pm-seg { display: block; height: 100%; min-width: 2px; cursor: default; }
.pm-seg.pm-exact { background: var(--pm-exact); } .pm-seg.pm-verified { background: var(--pm-verified); }
.pm-seg.pm-readable { background: repeating-linear-gradient(135deg, var(--pm-readable) 0 4px, color-mix(in srgb, var(--pm-readable) 45%, var(--surface-3)) 4px 8px); }
.pm-seg.pm-named { background: var(--blue); }
.pm-commit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; font-size: 13px; color: var(--muted); } .pm-commit b { color: var(--text); font-weight: 600; } .pm-commit .grow { flex: 1; }
.pm-tools { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 16px; } .pm-tools select { max-width: min(100%, 340px); } .pm-tools input { flex: 1 1 220px; min-width: 0; }
.pm-crumb { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-top: 12px; font-size: 13px; } .pm-crumb:empty { display: none; } .pm-crumb .pm-bar-row { flex: 1 1 100%; }
.pm-wrap { position: relative; margin-top: 12px; } canvas#pmMap { width: 100%; height: 560px; display: block; border-radius: 9px; cursor: pointer; }
.pm-tip { position: fixed; z-index: 50; pointer-events: none; max-width: 340px; padding: 8px 10px; border-radius: 8px; background: var(--surface); color: var(--text); border: 1px solid var(--line-2); box-shadow: var(--shadow-3); font-size: 12.5px; line-height: 1.45; }
.pm-tip .mono { font-family: var(--mono); font-size: 11.5px; color: var(--muted); } .pm-tip b { overflow-wrap: anywhere; }
@media (max-width: 700px) { canvas#pmMap { height: 380px; } .pm-title { font-size: 19px; } }
.lb-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 20px; }
.lb-head .chip { display: inline-block; font: 600 11.5px/1 var(--font, inherit); letter-spacing: .06em; text-transform: uppercase; color: var(--accent-ink); background: var(--accent-soft); border-radius: 999px; padding: 6px 10px; }
.lb-head p { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
.lb-stamp { font: 12px/1.5 var(--mono); color: var(--muted); text-align: right; } .lb-stamp b { color: var(--text); font-weight: 600; }
.lb-figs { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; }
.lb-fig { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-2, 12px); padding: 14px 16px; }
.lb-fig .v { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; } .lb-fig .l { color: var(--muted); font-size: 13px; margin-top: 4px; } .lb-fig .n { color: var(--faint); font: 11.5px/1.35 var(--mono); margin-top: 6px; }
canvas#lbMap { width: 100%; height: 150px; display: block; }
.lb-axis { display: flex; justify-content: space-between; font: 11.5px var(--mono); color: var(--faint); margin-top: 6px; }
.lb-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12.5px; color: var(--muted); }
.lb-sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.lb-stack { display: flex; height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; margin-top: 10px; }
.lb-stack i { display: block; height: 100%; min-width: 2px; }
.lb-hatch { background: repeating-linear-gradient(135deg, var(--surface-3) 0 5px, var(--line-2) 5px 10px) !important; }
.lb-cat { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 2px 10px; padding: 10px 0; border-top: 1px solid var(--line); align-items: start; }
.lb-cat .sw { width: 12px; height: 12px; border-radius: 3px; margin-top: 4px; } .lb-cat .plan { color: var(--muted); font-size: 12.5px; }
.lb-cat .nums { text-align: right; font: 12.5px/1.45 var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); } .lb-cat .nums b { color: var(--text); }
.lb-cat .sub { grid-column: 2 / 4; margin-top: 8px; }
.lb-big { font: 700 26px/1.1 var(--mono); font-variant-numeric: tabular-nums; }
svg#lbTrend { width: 100%; height: 110px; display: block; }
.lb-ms h3 { font-size: 14px; margin: 0 0 6px; display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; } .lb-ms h3 .code { font-family: var(--mono); color: var(--accent-ink); }
.lb-ms { padding: 10px 0; border-top: 1px solid var(--line); } .lb-ms:first-child { border-top: 0; }
.lb-ms ul { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } .lb-ms li { display: grid; grid-template-columns: 16px 1fr; gap: 6px; font-size: 13px; color: var(--muted); }
.lb-ms li::before { content: "○"; } .lb-ms li.done { color: var(--text); } .lb-ms li.done::before { content: "●"; color: var(--green); }
.lb-phase { font-weight: 700; color: var(--accent-ink); margin-top: 10px; font-size: 13px; letter-spacing: .04em; text-transform: uppercase; }
.lb-log { list-style: none; margin: 0; padding: 0; } .lb-log li { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-top: 1px solid var(--line); }
.lb-log li:first-child { border-top: 0; } .lb-log time { font: 11.5px/1.5 var(--mono); color: var(--faint); }
.lb-log .kind { font: 700 10px/1 inherit; letter-spacing: .08em; text-transform: uppercase; color: var(--accent-ink); margin-right: 8px; }
.lb-log .t { font-weight: 600; } .lb-log .d { color: var(--muted); font-size: 13px; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 4px; }
@media (max-width: 560px) { .lb-log li { grid-template-columns: 1fr; gap: 2px; } .lb-stamp { text-align: left; } }
.tierbar { display: flex; height: 22px; border-radius: 7px; overflow: hidden; background: var(--surface-3); gap: 2px; }
.tierbar span { display: block; height: 100%; min-width: 3px; }
.tier-exact { background: var(--green); } .tier-verified { background: var(--blue); } .tier-readable { background: var(--violet); }
.tier-deferred { background: var(--amber); } .tier-raw { background: var(--line-2); }
.tier-library, .tier-engine { background: repeating-linear-gradient(45deg, var(--surface-3) 0 4px, var(--line-2) 4px 8px); }
.tier-key { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 8px; vertical-align: -1px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.fn-head { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; margin-top: 16px; }
.fn-head h3 { margin: 0; font-size: 16px; }
.fn-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 7px 16px; font-size: 13px; margin: 14px 0 0; }
.fn-facts dt { color: var(--muted); } .fn-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.disasm { white-space: pre; font: 11.5px/1.5 var(--mono); max-height: 440px; overflow: auto; padding: 10px 12px; border-radius: 9px; background: var(--surface-3); margin: 8px 0 0; }
.band { padding: 12px 18px; border-top: 1px solid var(--line); } .band:first-child { border-top: 0; }
.band .cell-who { gap: 8px; } .band ul { margin: 8px 0 0; }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 460px), 1fr)); gap: 20px; align-items: start; }
.sub-h { font-size: 11px; letter-spacing: .8px; text-transform: uppercase; color: var(--faint); font-weight: 700; margin: 20px 0 8px; }
.flush th:first-child, .flush td:first-child { padding-left: 18px; }
.flush th:last-child, .flush td:last-child { padding-right: 18px; }
.flush > .note { padding: 10px 18px 14px; }
/* work control */
.hero {
--tone: var(--green); --tone-soft: var(--green-soft);
position: relative; overflow: hidden; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; align-items: center;
padding: 20px 22px; border-radius: var(--r-4); border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-2);
animation: rise .35s var(--ease) both;
}
.hero::before { content: ""; position: absolute; inset: 0; pointer-events: none; background: radial-gradient(640px 220px at 0% 0%, var(--tone-soft), transparent 70%); }
.hero > * { position: relative; }
.hero.partial { --tone: var(--amber); --tone-soft: var(--amber-soft); }
.hero.stopped { --tone: var(--red); --tone-soft: var(--red-soft); }
.hero-icon { width: 54px; height: 54px; border-radius: 17px; display: grid; place-items: center; background: var(--tone-soft); color: var(--tone); }
.hero-icon svg.i { width: 24px; height: 24px; stroke-width: 2; }
.eyebrow { font-size: 11px; font-weight: 750; letter-spacing: 1px; text-transform: uppercase; color: var(--tone); }
.hero h2 { margin: 2px 0 3px; font: 700 21px/1.25 var(--font-display); letter-spacing: -.45px; }
.hero-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.hero-actions input { width: 250px; height: 34px; }
/* stat tiles */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); gap: 14px; }
.stat {
--tone: var(--accent); --tone-soft: var(--accent-soft);
display: flex; align-items: center; gap: 13px; text-align: left; padding: 14px 16px; border-radius: var(--r-3);
border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow-1); color: var(--text);
transition: transform .18s var(--ease), box-shadow .18s, border-color .18s;
}
.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow-2); border-color: var(--line-2); }
.stat-icon { width: 42px; height: 42px; border-radius: 13px; display: grid; place-items: center; flex-shrink: 0; background: var(--tone-soft); color: var(--tone); }
.stat-icon svg.i { width: 19px; height: 19px; }
.stat-v { display: block; font: 700 23px/1.1 var(--font-display); letter-spacing: -.6px; font-variant-numeric: tabular-nums; }
.stat-l { display: block; font-size: 12px; font-weight: 650; color: var(--text-2); margin-top: 2px; }
.stat-s { display: block; font-size: 11.5px; color: var(--muted); }
.stat.green, .hero .green { --tone: var(--green); --tone-soft: var(--green-soft); }
.stat.red { --tone: var(--red); --tone-soft: var(--red-soft); }
.stat.amber { --tone: var(--amber); --tone-soft: var(--amber-soft); }
.stat.violet { --tone: var(--violet); --tone-soft: var(--violet-soft); }
.stat.blue { --tone: var(--blue); --tone-soft: var(--blue-soft); }
.stat.quiet { --tone: var(--muted); --tone-soft: var(--surface-3); }
/* questions and attention */
.card.ask { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); box-shadow: 0 0 0 4px var(--accent-soft), var(--shadow-2); }
.card.warn > header h2 svg.i { color: var(--amber); }
.card.ask > header h2 svg.i { color: var(--accent); }
.qitem { display: flex; gap: 12px; padding: 14px 0; border-top: 1px solid var(--line); }
.qitem:first-child { border-top: 0; padding-top: 0; }
.qitem:last-child { padding-bottom: 0; }
.qitem .who { font-size: 12px; color: var(--muted); }
.qitem .qt { font-size: 14px; font-weight: 550; margin: 2px 0 10px; overflow-wrap: anywhere; }
.attn { list-style: none; margin: 0; padding: 0; }
.attn li { display: flex; gap: 10px; align-items: baseline; padding: 9px 0; border-top: 1px solid var(--line); font-size: 13px; overflow-wrap: anywhere; }
.attn li:first-child { border-top: 0; padding-top: 0; }
.attn li:last-child { padding-bottom: 0; }
.attn .tag { flex-shrink: 0; }
/* crew */
.crew { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: 14px; }
.agent {
display: flex; flex-direction: column; gap: 11px; padding: 16px; min-width: 0; border-radius: var(--r-3);
border: 1px solid var(--line); background: var(--surface-2); transition: border-color .2s, box-shadow .2s;
}
.agent:hover { border-color: var(--line-2); box-shadow: var(--shadow-1); }
.agent.working { border-color: color-mix(in srgb, var(--green) 45%, var(--line)); background: linear-gradient(180deg, color-mix(in srgb, var(--green-soft) 70%, var(--surface-2)), var(--surface-2) 60%); }
.agent-top { display: flex; gap: 12px; align-items: center; }
.agent .name { font: 650 14.5px/1.25 var(--font-display); }
.agent .role { font-size: 12px; color: var(--muted); }
.agent-now { display: flex; flex-direction: column; gap: 3px; }
.doing { font-size: 12.5px; color: var(--text-2); overflow-wrap: anywhere; }
.doing .when { color: var(--faint); }
.gitline {
display: flex; flex-wrap: wrap; align-items: center; gap: 3px 9px; padding: 7px 10px; border-radius: 9px;
font: 11.5px/1.4 var(--mono); color: var(--muted); background: var(--surface-3); overflow-wrap: anywhere;
}
.gitline b { color: var(--text); font-weight: 600; }
.gitwarn { color: var(--amber); }
.links { display: flex; flex-wrap: wrap; gap: 5px; }
.handoff summary { cursor: pointer; font-size: 12px; color: var(--muted); }
.handoff summary:hover { color: var(--text); }
.handoff pre { white-space: pre-wrap; font: 11.5px/1.5 var(--mono); margin: 6px 0 0; max-height: 220px; overflow: auto; padding: 10px; border-radius: 9px; background: var(--surface-3); }
.agent-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: auto; padding-top: 2px; }
.agent-actions .tag { margin-left: auto; }
/* launch */
.stepper { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
.stepper li { display: flex; gap: 12px; align-items: flex-start; padding: 15px 16px; border-radius: var(--r-3); background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
.stepper .n { width: 30px; height: 30px; border-radius: 10px; display: grid; place-items: center; font-weight: 750; font-size: 13px; background: var(--accent-soft); color: var(--accent-ink); flex-shrink: 0; }
.stepper b { display: block; font-size: 13px; }
.stepper .d { font-size: 12px; color: var(--muted); }
.launch td { vertical-align: middle; }
.launch .who { display: flex; align-items: center; gap: 10px; }
.step { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; white-space: nowrap; color: var(--muted); }
.step i { width: 20px; height: 20px; border-radius: 50%; display: inline-grid; place-items: center; font-style: normal; font-size: 11px; font-weight: 800; background: var(--surface-3); color: var(--faint); }
.step.done { color: var(--text); }
.step.done i { background: var(--green); color: var(--on-green); box-shadow: 0 0 0 3px var(--green-soft); }
.step.wait { color: var(--amber); }
.step.wait i { background: var(--amber-soft); color: var(--amber); }
.acts { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; align-items: center; }
.acts select { height: 28px; padding: 0 24px 0 9px; font-size: 12px; }
/* tasks */
.taskform { display: grid; grid-template-columns: minmax(0, 2.4fr) repeat(4, minmax(0, 1fr)) auto; gap: 10px; }
.taskform input, .taskform select { height: 36px; }
.taskform .btn { height: 36px; }
.bulk { margin-top: 16px; padding-top: 14px; border-top: 1px dashed var(--line-2); }
.bulk summary { cursor: pointer; font-size: 12.5px; font-weight: 600; color: var(--accent); display: inline-flex; align-items: center; gap: 7px; }
.bulk summary::-webkit-details-marker { display: none; }
.bulk summary::marker { content: ""; }
.bulk textarea { min-height: 130px; font: 12.5px/1.55 var(--mono); margin-top: 10px; }
.prio { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; text-transform: capitalize; white-space: nowrap; }
.prio::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.prio-urgent { color: var(--red); font-weight: 700; } .prio-urgent::before { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }
.prio-high { color: var(--amber); font-weight: 650; } .prio-high::before { background: var(--amber); }
.prio-normal::before { background: var(--blue); }
.prio-low { color: var(--muted); } .prio-low::before { background: var(--line-2); }
.cell-who { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
/* activity */
.feed { list-style: none; margin: 0; padding: 0; max-height: 460px; overflow: auto; font-size: 12.5px; }
.feed li { display: grid; grid-template-columns: 52px 120px 110px minmax(0, 1fr); gap: 10px; align-items: center; padding: 7px 2px; border-top: 1px solid var(--line); }
.feed li:first-child { border-top: 0; }
.feed li.empty { display: grid; grid-template-columns: 1fr; }
.feed .t { color: var(--faint); font-variant-numeric: tabular-nums; font-size: 11.5px; }
.feed .who { display: flex; align-items: center; gap: 8px; font-weight: 600; min-width: 0; }
.feed .tg { font: 11.5px var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pulse-row, .pulse-axis { display: grid; grid-template-columns: 120px minmax(0, 1fr) 96px; gap: 14px; align-items: center; }
.pulse-row { margin: 7px 0; font-size: 12.5px; }
.pulse-row .who { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.pulse-row .bar { height: 24px; border-radius: 7px; overflow: hidden; background: var(--surface-3); }
.pulse-row svg { width: 100%; height: 100%; display: block; }
.pulse-axis { font-size: 11px; color: var(--faint); margin-top: 8px; }
.pulse-axis div:nth-child(2) { display: flex; justify-content: space-between; }
.metric { display: grid; grid-template-columns: minmax(0, 1fr) 130px 64px; gap: 12px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); }
.metric:first-child { border-top: 0; padding-top: 0; }
.metric .v { font: 700 25px/1.1 var(--font-display); letter-spacing: -.6px; font-variant-numeric: tabular-nums; }
.metric .d { font-size: 12px; font-weight: 700; color: var(--green); text-align: right; }
.metric .d.neg { color: var(--red); }
.spark { width: 130px; height: 34px; display: block; }
/* knowledge */
.searchbar { display: flex; gap: 10px; }
.searchbar .field { position: relative; flex: 1; min-width: 0; }
.searchbar .field svg.i { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--faint); }
.searchbar input { width: 100%; height: 44px; padding-left: 40px; font-size: 14px; border-radius: var(--r-2); }
.searchbar .btn { height: 44px; padding: 0 18px; }
#results:not(:empty) { margin-top: 16px; }
.msgs { display: flex; flex-direction: column; gap: 10px; }
#pins { max-height: 560px; overflow: auto; }
#threadMsgs { max-height: 54vh; overflow: auto; padding-right: 4px; }
.msg { display: flex; gap: 11px; padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-2); background: var(--surface-2); }
.msg.urgent { border-color: color-mix(in srgb, var(--red) 50%, var(--line)); background: color-mix(in srgb, var(--red-soft) 55%, var(--surface-2)); }
.msg-main { flex: 1; min-width: 0; }
.msg .h { font-size: 12px; color: var(--muted); display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
.msg .h strong { color: var(--text); }
.msg .b { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; }
.threads { list-style: none; margin: 0; padding: 0; max-height: 560px; overflow: auto; }
.threads li { display: flex; gap: 11px; align-items: center; padding: 10px; border-radius: 10px; cursor: pointer; transition: background .15s; }
.threads li:hover { background: var(--surface-2); }
.threads li > svg.i { color: var(--faint); }
.threads .t { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: 13px; }
.threads .s { font-size: 12px; color: var(--muted); white-space: nowrap; }
.ctx { white-space: pre-wrap; font: 12px/1.55 var(--mono); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-2); padding: 12px 14px; }
.summary { background: var(--accent-soft); border-radius: var(--r-2); padding: 12px 14px; font-size: 13px; white-space: pre-wrap; }
/* dialogs */
#promptText { min-height: 50vh; font: 12px/1.5 var(--mono); }
.radio-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.field-label { display: block; font-size: 12px; font-weight: 650; color: var(--text-2); margin: 14px 0 6px; }
dialog .body input[type=text], dialog .body select { width: 100%; }
@media (max-width: 1100px) {
.taskform { grid-template-columns: 1fr 1fr; }
.taskform #tTitle { grid-column: 1 / -1; }
}
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; }
.side { position: fixed; inset: 0 auto 0 0; width: min(284px, 86vw); transform: translateX(-104%); transition: transform .26s var(--ease); background: var(--side-solid); box-shadow: var(--shadow-3); }
.shell.open .side { transform: none; }
.scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(8, 12, 20, .45); opacity: 0; pointer-events: none; transition: opacity .2s; }
.shell.open .scrim { opacity: 1; pointer-events: auto; }
#menuBtn { display: inline-flex; }
.topbar { padding: 12px 16px; gap: 10px; }
.topbar .sub { display: none; }
.topbar h1 { font-size: 18px; }
.content { padding: 16px 16px 40px; }
.hero { grid-template-columns: auto minmax(0, 1fr); padding: 18px; }
.hero-actions { grid-column: 1 / -1; justify-content: stretch; }
.hero-actions input { flex: 1 1 100%; width: auto; }
.hero-actions .btn { flex: 1; }
.feed li { grid-template-columns: 46px minmax(0, 1fr); }
.feed .tool, .feed .tg { grid-column: 2; }
.pulse-row, .pulse-axis { grid-template-columns: 92px minmax(0, 1fr); }
.pulse-row > :last-child, .pulse-axis > :last-child { display: none; }
}
@media (max-width: 560px) {
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat { padding: 12px; gap: 10px; }
.stat-icon { width: 34px; height: 34px; border-radius: 11px; }
.stat-s { display: none; }
.searchbar { flex-direction: column; }
.metric { grid-template-columns: minmax(0, 1fr) 90px 52px; }
.spark { width: 90px; }
}
/* top bar: the same in every section */
.view-icon { width: 40px; height: 40px; border-radius: 13px; display: grid; place-items: center; flex-shrink: 0; background: var(--accent-soft); color: var(--accent); }
.view-icon svg.i { width: 20px; height: 20px; }
.view-icon.is-face { background: transparent; }
.view-icon .avatar { width: 40px; height: 40px; border-radius: 13px; }
.topbar .title { min-width: 0; }
.topbar h1, .topbar .sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.top-actions .avail { margin-left: 4px; }
/* chat: the log and composer on the left, the people on the right */
.content.flat { padding: 0; overflow: hidden; }
#view-chat { max-width: none; height: 100%; gap: 0; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
.chat-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
/* the chat's people, in the sidebar under the board's sections */
.person {
display: flex; align-items: center; gap: 10px; width: 100%; padding: 6px 10px; border: 0; border-radius: 9px;
background: transparent; color: var(--text-2); text-align: left; transition: background .15s;
}
.person:hover { background: var(--surface-3); }
.person[aria-current="page"] { background: var(--accent-soft); }
.person[aria-current="page"] .p-name { color: var(--accent-ink); }
.p-copy { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.p-name, .p-sub, .p-doing { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.p-name { font-size: 13px; font-weight: 600; color: var(--text); }
.p-sub, .p-doing { font-size: 11.5px; color: var(--muted); }
.p-doing { color: var(--text-2); opacity: .8; }
.p-work { color: var(--green); font-weight: 650; }
.p-work::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 5px; vertical-align: 1px; border-radius: 50%; background: currentColor; animation: pulse 1.4s infinite; }
.person .count-badge { margin-left: auto; }
.everyone { --avatar: var(--action); color: var(--on-action); }
.stopbar, .threadbar { display: none; align-items: center; gap: 10px; padding: 9px 28px; font-size: 12.5px; flex-shrink: 0; }
.stopbar { color: var(--red-ink); font-weight: 650; background: var(--red-soft); border-bottom: 1px solid color-mix(in srgb, var(--red) 22%, transparent); }
.stopbar button { margin-left: auto; border: 0; background: none; padding: 0; color: inherit; font: inherit; text-decoration: underline; white-space: nowrap; }
.threadbar { color: var(--accent-ink); background: var(--accent-soft); border-bottom: 1px solid var(--line); }
.threadbar .grow { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stopbar.on, .threadbar.on { display: flex; }
/* the log */
.log-wrap { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
.log { position: relative; flex: 1; min-height: 0; overflow: auto; padding: 8px 32px 28px; overscroll-behavior: contain; }
.log > * { max-width: 1040px; margin-left: auto; margin-right: auto; }
/* rows out of view are not laid out or painted (the browser keeps their last size) */
.log > .mrow { content-visibility: auto; contain-intrinsic-size: auto 90px; }
.date { display: flex; align-items: center; gap: 14px; margin-top: 26px; margin-bottom: 8px; font-size: 11px; font-weight: 650; color: var(--muted); }
.date::before, .date::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.date span { padding: 3px 11px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-1); }
.mrow { display: flex; align-items: flex-end; gap: 10px; margin-top: 16px; }
.mrow.own { flex-direction: row-reverse; }
.mrow.cont { margin-top: 3px; }
.mrow.cont .avatar { visibility: hidden; }
.mrow .avatar { width: 32px; height: 32px; border-radius: 11px; font-size: 10.5px; }
.stack { max-width: min(720px, 78%); min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
.own .stack { align-items: flex-end; }
.meta { font-size: 11.5px; color: var(--muted); padding: 0 4px 5px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.meta strong { color: var(--text); font-weight: 650; }
.meta .arrow { color: var(--faint); }
.mrow.cont .meta { display: none; }
.bubble {
background: var(--surface); border: 1px solid var(--line); border-radius: 18px 18px 18px 6px; padding: 10px 15px;
white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13.5px; line-height: 1.6; box-shadow: var(--shadow-1);
}
.mrow.cont .bubble { border-top-left-radius: 6px; }
.own .bubble { background: linear-gradient(160deg, var(--action), var(--action-2)); border-color: transparent; color: var(--on-action); border-radius: 18px 18px 6px 18px; box-shadow: 0 8px 18px -12px var(--action); }
.own.cont .bubble { border-top-right-radius: 6px; }
.bubble.urgent { border-color: var(--red); box-shadow: inset 0 0 0 1px var(--red), 0 6px 18px -10px var(--red); }
.own .bubble.urgent { box-shadow: 0 0 0 2px var(--red); }
.bubble code { font: 12px/1.6 var(--mono); background: var(--surface-3); padding: 1px 5px; border-radius: 5px; }
.own .bubble code { background: color-mix(in srgb, var(--on-action) 18%, transparent); }
.bubble a { color: inherit; text-decoration: underline; }
.files { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; white-space: normal; }
.files a { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; border: 1px solid var(--line); border-radius: 8px; padding: 3px 9px; background: var(--surface-2); color: var(--accent); text-decoration: none; }
.own .files a { background: color-mix(in srgb, var(--on-action) 15%, transparent); border-color: color-mix(in srgb, var(--on-action) 25%, transparent); color: var(--on-action); }
.foot { color: var(--faint); font-size: 11px; padding: 5px 4px 0; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.own .foot { justify-content: flex-end; }
.ackbtn { font-size: 11.5px; font-weight: 650; border: 1px solid var(--accent); color: var(--accent-ink); background: var(--accent-soft); border-radius: 7px; padding: 2px 10px; }
.ackbtn:hover { background: var(--action); color: var(--on-action); }
.linkbtn { font-size: 11px; border: 0; background: transparent; color: var(--muted); padding: 0; cursor: pointer; opacity: 0; transition: opacity .15s; }
.linkbtn:hover { color: var(--accent); }
.mrow:hover .linkbtn, .linkbtn:focus-visible { opacity: 1; }
@media (hover: none) { .linkbtn { opacity: 1; } }
.chip { display: inline-flex; align-items: center; height: 19px; font-size: 10.5px; font-weight: 650; padding: 0 8px; border-radius: 999px; background: var(--surface-3); color: var(--muted); border: 0; margin-left: 2px; }
button.chip { cursor: pointer; }
button.chip:hover { color: var(--accent-ink); background: var(--accent-soft); }
.chip.urgent, .chip.kind-stop { background: var(--red-soft); color: var(--red-ink); }
.chip.high, .chip.pin { background: var(--amber-soft); color: var(--amber-ink); }
.chip.kind-resume, .chip.kind-answer { background: var(--green-soft); color: var(--green-ink); }
.chip.kind-finding, .chip.kind-retraction, .chip.fixed, .chip.kind-decision { background: var(--violet-soft); color: var(--violet-ink); }
.chip.kind-question, .chip.kind-handoff { background: var(--blue-soft); color: var(--blue-ink); }
.qbox { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; white-space: normal; }
.qbtn { font-size: 12.5px; font-weight: 650; border: 1px solid var(--accent); color: var(--accent-ink); background: var(--surface); border-radius: 9px; padding: 5px 13px; transition: background .15s, color .15s; }
.qbtn:hover { background: var(--action); color: var(--on-action); }
.own .qbtn { color: var(--on-action); border-color: color-mix(in srgb, var(--on-action) 50%, transparent); background: color-mix(in srgb, var(--on-action) 12%, transparent); }
.answered { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 650; color: var(--green-ink); }
.answered.waiting { color: var(--muted); }
.own .answered { color: var(--on-action); }
.earlier { display: flex; align-items: center; gap: 6px; width: fit-content; margin: 10px auto 4px; font-size: 12px; font-weight: 600; color: var(--accent); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; box-shadow: var(--shadow-1); }
.earlier:hover { border-color: var(--accent); }
.log > .empty { margin-top: 12vh; }
.log > .empty svg.i { width: 44px; height: 44px; padding: 10px; border-radius: 16px; background: var(--accent-soft); color: var(--accent); stroke-width: 1.6; }
.new-messages {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 2; display: inline-flex; align-items: center; gap: 6px;
border: 0; background: var(--action); color: var(--on-action); font-size: 12.5px; font-weight: 650; border-radius: 999px; padding: 8px 16px; box-shadow: var(--shadow-3);
animation: rise .25s var(--ease);
}
/* composer */
.composer-area { flex-shrink: 0; padding: 12px 28px 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 60%, transparent); }
.composer { max-width: 1040px; margin: 0 auto; background: var(--surface); border: 1px solid var(--line-2); border-radius: 18px; box-shadow: var(--shadow-2); transition: border-color .15s, box-shadow .15s; }
.composer:focus-within { border-color: var(--accent); box-shadow: var(--ring), var(--shadow-2); }
.recipient { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 10px 12px 0 14px; font-size: 12px; color: var(--muted); }
.recipient select { height: 28px; padding: 0 26px 0 11px; border-radius: 999px; border-color: transparent; background-color: var(--accent-soft); color: var(--accent-ink); font-weight: 650; font-size: 12px; max-width: 200px; }
.toggle-chip span svg.i { width: 13px; height: 13px; }
.quick { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
.quick button { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px; font-size: 11.5px; font-weight: 600; border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 999px; transition: all .15s; }
.quick button:hover { color: var(--accent-ink); border-color: transparent; background: var(--accent-soft); }
.quick svg.i { width: 13px; height: 13px; }
.input-row { display: flex; align-items: flex-end; gap: 8px; padding: 6px 10px 10px 16px; }
#text, #text:focus { border: 0; background: transparent; box-shadow: none; outline: none; resize: none; padding: 7px 0; flex: 1; width: auto; min-width: 0; min-height: 36px; max-height: 180px; font-size: 14px; line-height: 1.5; }
.mic, .send { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; flex-shrink: 0; transition: background .15s, transform .08s, color .15s; }
.mic { border: 1px solid var(--line-2); background: var(--surface); color: var(--muted); }
.mic:hover { color: var(--text); background: var(--surface-2); }
.mic.on { background: var(--red); border-color: var(--red); color: var(--on-red); animation: pulse 1.4s infinite; }
.send { border: 0; background: var(--action); color: var(--on-action); box-shadow: 0 8px 16px -10px var(--action); }
.send:hover { background: var(--action-2); }
.send:active { transform: scale(.95); }
.send:disabled { opacity: .45; cursor: default; }
.send svg.i { width: 18px; height: 18px; stroke-width: 2.2; }
.composer-footer { max-width: 1040px; margin: 0 auto; display: flex; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--faint); padding: 8px 6px 0; }
#feedback.error { color: var(--red); }
#feedback.cmdhelp { flex: 1; white-space: pre-wrap; font: 11.5px/1.55 var(--mono); color: var(--text-2); background: var(--surface-2); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
/* the catch-up dialog */
#catchup { width: min(720px, calc(100vw - 32px)); }
#catchBody ul { list-style: none; margin: 0; padding: 0; }
#catchBody li { display: flex; gap: 10px; align-items: center; padding: 8px 0; border-top: 1px solid var(--line); font-size: 13px; }
#catchBody li:first-child { border-top: 0; }
#catchBody li .avatar { width: 26px; height: 26px; border-radius: 8px; font-size: 9px; }
@media (max-width: 1180px) { .top-actions .lbl { display: none; } .top-actions .btn { width: 34px; padding: 0; } }
@media (max-width: 900px) {
.log { padding: 4px 16px 20px; }
.composer-area { padding: 10px 12px; }
.stopbar, .threadbar { padding: 8px 16px; }
.stack { max-width: 85%; }
}
@media (max-width: 560px) {
.top-actions .btn, .view-icon { display: none; }
.mrow .avatar { width: 26px; height: 26px; border-radius: 9px; font-size: 9px; }
.stack { max-width: 86%; }
.quick { margin-left: 0; width: 100%; }
.composer-footer .footer-right { display: none; }
}
</style>
</head>
<body>
<div class="shell" id="shell">
<aside class="side" aria-label="Board navigation">
<div>
<div class="brand"><div class="logo" aria-hidden="true"><svg class="i"><use href="static/icons.svg#anchor"/></svg></div>
<div class="grow"><div class="brand-name" id="boardTitle">Harbor</div><div class="brand-sub">Harbor</div></div></div>
<select id="projectSelect" aria-label="Project" style="margin-top:14px" hidden></select>
</div>
<nav class="nav" aria-label="Sections">
<div class="nav-label">Board</div>
<button class="nav-btn" data-go="overview" title="Overview (1)"><svg class="i"><use href="static/icons.svg#home"/></svg><span>Overview</span><span class="count-badge" id="navOverview" hidden></span></button>
<button class="nav-btn" data-go="logbook" title="Logbook (9)"><svg class="i"><use href="static/icons.svg#anchor"/></svg><span>Logbook</span></button>
<button class="nav-btn" data-go="launch" title="Launch (2)"><svg class="i"><use href="static/icons.svg#rocket"/></svg><span>Launch</span><span class="count-badge" id="navLaunch" hidden></span></button>
<button class="nav-btn" data-go="tasks" title="Tasks (3)"><svg class="i"><use href="static/icons.svg#tasks"/></svg><span>Tasks</span><span class="count-badge" id="navTasks" hidden></span></button>
<button class="nav-btn" data-go="review" title="Review (4)"><svg class="i"><use href="static/icons.svg#review"/></svg><span>Review</span><span class="count-badge" id="navReview" hidden></span></button>
<button class="nav-btn" data-go="decomp" title="Decomp (8)"><svg class="i"><use href="static/icons.svg#terminal"/></svg><span>Decomp</span></button>
<button class="nav-btn" data-go="activity" title="Activity (5)"><svg class="i"><use href="static/icons.svg#activity"/></svg><span>Activity</span><span class="count-badge" id="navActivity" hidden></span></button>
<button class="nav-btn" data-go="knowledge" title="Knowledge (6)"><svg class="i"><use href="static/icons.svg#book"/></svg><span>Knowledge</span><span class="count-badge" id="navKnowledge" hidden></span></button>
<button class="nav-btn" data-go="health" title="Health (7)"><svg class="i"><use href="static/icons.svg#heart"/></svg><span>Health</span></button>
</nav>
<nav class="nav" aria-label="Chat">
<div class="nav-label">Chat</div>
<button class="person" data-filter="All" title="The whole team's conversation (C)"><div class="avatar sm everyone" aria-hidden="true">#</div><span class="p-copy"><span class="p-name">Everyone</span><span class="p-sub">The whole team</span></span><span class="count-badge" id="chatUnread" hidden></span></button>
<div id="participants"></div>
</nav>
<div class="side-foot">
<div class="row"><button type="button" class="theme-btn" data-theme-open title="Colour theme and mode"><svg class="i"><use href="static/icons.svg#palette"/></svg><span data-theme-name>Harbor</span></button>
<div class="segmented theme" role="radiogroup" aria-label="Theme">
<label title="Match the system"><input type="radio" name="theme" value="auto" aria-label="System theme"><span><svg class="i sm"><use href="static/icons.svg#monitor"/></svg></span></label>
<label title="Light"><input type="radio" name="theme" value="light" aria-label="Light theme"><span><svg class="i sm"><use href="static/icons.svg#sun"/></svg></span></label>
<label title="Dark"><input type="radio" name="theme" value="dark" aria-label="Dark theme"><span><svg class="i sm"><use href="static/icons.svg#moon"/></svg></span></label>
</div></div>
<div class="live" id="conn"><span class="dot"></span><span id="connText">Connecting</span></div>
</div>
</aside>
<div class="scrim" id="scrim"></div>
<div class="main">
<header class="topbar">
<button class="btn icon ghost" id="menuBtn" aria-label="Open the menu"><svg class="i lg"><use href="static/icons.svg#menu"/></svg></button>
<div class="view-icon" id="viewIcon" aria-hidden="true"></div>
<div class="grow title"><h1 id="viewTitle">Harbor</h1><div class="sub" id="viewSub"></div></div>
<div class="top-actions">
<button class="btn ghost" id="sound" title="Sound for messages to you: off, beep, or read aloud">Sound off</button>
<button class="btn ghost" id="notify" aria-pressed="false" title="Desktop notifications for messages to you">Notify</button>
<button class="avail" id="availBtn" title="Tell the team whether you are around">Here</button>
</div>
</header>
<main class="content" id="content">
<!-- chat -->
<section class="view" id="view-chat" aria-label="Chat">
<div class="chat-main">
<div class="stopbar" id="stopbar"></div>
<div class="threadbar" id="threadbar"><svg class="i"><use href="static/icons.svg#thread"/></svg><strong class="grow" id="threadName"></strong>
<button class="btn sm" id="settleChatThread" title="Mark this thread settled">Close thread</button><button class="btn sm primary" id="leaveThread">Back to all</button></div>
<div class="log-wrap">
<div class="log" id="log" role="log" aria-label="Messages" aria-live="polite"><div class="empty"><strong>Your team’s conversation</strong>Connecting…</div></div>
<button class="new-messages" id="newMessages" hidden>New messages <svg class="i sm"><use href="static/icons.svg#chevron"/></svg></button>
</div>
<div class="composer-area"><form class="composer" id="composer">
<div class="recipient"><label for="to">To</label><select id="to" aria-label="Recipient"></select>
<label class="toggle-chip"><input type="checkbox" id="askAck"><span><svg class="i"><use href="static/icons.svg#check"/></svg>Ask to acknowledge</span></label>
<label class="toggle-chip danger"><input type="checkbox" id="urgent"><span><svg class="i"><use href="static/icons.svg#zap"/></svg>Urgent</span></label>
<div class="quick" aria-label="Quick replies">
<button type="button" data-quick="night" title="Tell the team you are away until 8 AM"><svg class="i"><use href="static/icons.svg#moon-zz"/></svg>Good night</button>
<button type="button" data-quick="morning" title="Back, and catch up on what happened"><svg class="i"><use href="static/icons.svg#sunrise"/></svg>Good morning</button>
<button type="button" data-quick="status" title="The last 12 hours at a glance"><svg class="i"><use href="static/icons.svg#list"/></svg>Status?</button></div></div>
<div class="input-row"><textarea id="text" rows="1" maxlength="20000" placeholder="Write to the team… @Name addresses someone, /help lists commands" aria-label="Message"></textarea>
<button class="mic" id="mic" type="button" title="Dictate a message" aria-label="Dictate a message"><svg class="i"><use href="static/icons.svg#mic"/></svg></button>
<button class="send" id="send" type="submit" aria-label="Send" title="Send"><svg class="i"><use href="static/icons.svg#send"/></svg></button></div></form>
<div class="composer-footer"><span id="feedback">Enter to send · Shift + Enter for a new line</span><span class="footer-right">Central time</span></div></div>
</div>
</section>
<!-- overview -->
<section class="view" id="view-overview" aria-label="Overview">
<div class="hero" id="banner">
<div class="hero-icon" id="heroIcon"><svg class="i"><use href="static/icons.svg#play"/></svg></div>
<div class="grow"><div class="eyebrow">Work control</div><h2 id="bannerTitle">…</h2><div class="note" id="bannerNote"></div><div class="note faint" id="flagNote"></div></div>
<div class="hero-actions">
<input type="text" id="reason" placeholder="Reason (optional, shown to the team)" aria-label="Reason">
<button class="btn stop" id="stopAll"><svg class="i sm"><use href="static/icons.svg#stop"/></svg>Stop everyone</button>
<button class="btn go" id="resumeAll"><svg class="i sm"><use href="static/icons.svg#play"/></svg>Resume everyone</button>
</div>
</div>
<div class="stats" id="stats"></div>
<section class="card ask" aria-labelledby="h-questions" id="questionsCard" hidden>
<header><h2 id="h-questions"><svg class="i"><use href="static/icons.svg#help"/></svg>Questions for you</h2><span class="count" id="questionCount"></span></header>
<div class="body" id="questions"></div>
</section>
<section class="card warn" aria-labelledby="h-attention" id="attentionCard" hidden>
<header><h2 id="h-attention"><svg class="i"><use href="static/icons.svg#alert"/></svg>Needs attention</h2><span class="count" id="attentionCount"></span></header>
<div class="body"><ul class="attn" id="attention"></ul></div>
</section>
<section class="card" aria-labelledby="h-crew">
<header><h2 id="h-crew"><svg class="i"><use href="static/icons.svg#users"/></svg>Crew</h2><span class="count" id="crewCount"></span></header>
<div class="body"><div class="crew" id="agents"></div></div>
</section>
<section class="card" aria-labelledby="h-digest">
<header><h2 id="h-digest"><svg class="i"><use href="static/icons.svg#clock"/></svg>What happened</h2><span class="grow"></span>
<select id="since" aria-label="Time window"><option value="2h">Last 2 hours</option><option value="8h" selected>Last 8 hours</option><option value="16h">Last 16 hours</option><option value="24h">Last day</option><option value="3d">Last 3 days</option></select></header>
<div class="body scroll" id="digest"></div>
</section>
</section>
<!-- launch -->
<section class="view" id="view-launch" aria-label="Launch">
<ol class="stepper">
<li><span class="n">1</span><div><b>Open the app</b><span class="d">Send the agent any message; it reads its brief and shows up as connected.</span></div></li>
<li><span class="n">2</span><div><b>Roll call</b><span class="d">Ask it to answer "Here". Works while it is stopped.</span></div></li>
<li><span class="n">3</span><div><b>Trial run</b><span class="d">Let one agent work for 15 to 60 minutes; the stop returns by itself.</span></div></li>
<li><span class="n">4</span><div><b>Resume</b><span class="d">Watch the live feed, then let it work for real.</span></div></li>
</ol>
<section class="card" aria-labelledby="h-launch">
<header><h2 id="h-launch"><svg class="i"><use href="static/icons.svg#rocket"/></svg>Agents</h2><span class="grow"></span>
<button class="btn sm" id="rollcallAll" title="Ask every agent to answer 'Here' - works while they are stopped"><svg class="i sm"><use href="static/icons.svg#users"/></svg>Roll call everyone</button></header>
<div class="body flush scroll" id="launch"></div>
</section>
</section>
<!-- tasks -->
<section class="view" id="view-tasks" aria-label="Tasks">
<section class="card" aria-labelledby="h-newtask">
<header><h2 id="h-newtask"><svg class="i"><use href="static/icons.svg#plus"/></svg>New task</h2><span class="grow"></span><span class="note" id="taskNote"></span></header>
<div class="body">
<form class="taskform" id="taskForm"><input type="text" id="tTitle" placeholder="New task, e.g. Make 0x445310 readable" aria-label="Task title" required>
<input type="text" id="tScope" placeholder="Scope (fn:0x445310)" aria-label="Scope">
<select id="tAssignee" aria-label="Assign to"><option value="">Anyone (next)</option></select>
<select id="tPriority" aria-label="Priority"><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option><option value="low">Low</option></select>
<input type="text" id="tAfter" placeholder="After tasks (3, 4)" aria-label="Depends on">
<button class="btn primary"><svg class="i sm"><use href="static/icons.svg#plus"/></svg>Add task</button></form>
<details class="bulk"><summary><svg class="i sm"><use href="static/icons.svg#list"/></svg>Bulk add: one task per line ("0x445310 RouteCellCost" or a plain title); addresses go to their band owner</summary>
<form id="bulkForm"><textarea id="bulkText" placeholder="0x445310 RouteCellCost&#10;0x4E2680 CreateShipWakeModel&#10;Write the README" aria-label="Tasks, one per line"></textarea>
<div class="row" style="margin-top:10px"><select id="bulkTemplate" aria-label="Template"><option value="">No template</option></select>
<select id="bulkPriority" aria-label="Priority"><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option><option value="low">Low</option></select>
<span class="grow note" id="bulkNote"></span><button class="btn primary">Add all</button></div></form></details>
</div>
</section>
<section class="card" aria-labelledby="h-tasks">
<header><h2 id="h-tasks"><svg class="i"><use href="static/icons.svg#tasks"/></svg>Queue</h2><span class="count" id="taskCount"></span><span class="grow"></span>
<label class="switch"><input type="checkbox" id="tasksAll"> Include finished</label></header>
<div class="body flush scroll" id="tasks"></div>
</section>
</section>
<!-- review -->
<section class="view" id="view-review" aria-label="Review">
<section class="card" aria-labelledby="h-queue">
<header><h2 id="h-queue"><svg class="i"><use href="static/icons.svg#review"/></svg>Review queue</h2><span class="count" id="queueCount"></span></header>
<div class="body flush scroll" id="queue"></div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-acks">
<header><h2 id="h-acks"><svg class="i"><use href="static/icons.svg#check"/></svg>Waiting for acknowledgement</h2><span class="count" id="ackCount"></span></header>
<div class="body flush scroll" id="acks"></div>
</section>
<section class="card" aria-labelledby="h-claims">
<header><h2 id="h-claims"><svg class="i"><use href="static/icons.svg#flag"/></svg>Claims</h2><span class="count" id="claimCount"></span></header>
<div class="body flush scroll" id="claims"></div>
</section>
</div>
</section>
<!-- activity -->
<section class="view" id="view-activity" aria-label="Activity">
<section class="card" aria-labelledby="h-pulse">
<header><h2 id="h-pulse"><svg class="i"><use href="static/icons.svg#activity"/></svg>Team pulse</h2><span class="note">tool calls and messages, 5-minute steps</span><span class="grow"></span>
<select id="pulseHours" aria-label="Window"><option value="6">6 hours</option><option value="12" selected>12 hours</option><option value="24">24 hours</option><option value="48">48 hours</option></select></header>
<div class="body" id="pulse"></div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-feed">
<header><h2 id="h-feed"><svg class="i"><use href="static/icons.svg#zap"/></svg>Live feed</h2><span class="note">the latest tool calls, from the hooks</span></header>
<div class="body"><ul class="feed" id="feed"></ul></div>
</section>
<section class="card" aria-labelledby="h-score">
<header><h2 id="h-score"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Scoreboard</h2><span class="count" id="winCount"></span></header>
<div class="body"><div id="metrics"></div><h3 class="sub-h">Landed in the last 24 hours</h3><ul class="attn" id="wins"></ul></div>
</section>
</div>
</section>
<!-- knowledge -->
<section class="view" id="view-knowledge" aria-label="Knowledge">
<section class="card" aria-labelledby="h-search">
<header><h2 id="h-search"><svg class="i"><use href="static/icons.svg#search"/></svg>Search every message</h2></header>
<div class="body">
<form class="searchbar" id="searchForm"><div class="field"><svg class="i"><use href="static/icons.svg#search"/></svg>
<input type="search" id="q" placeholder="Words, fn:0x445310, or filters: from:Kimi kind:decision since:2d pinned:yes" aria-label="Search"></div>
<button class="btn primary">Search</button></form>
<div class="note" style="margin-top:8px">A scope like <code>fn:0x445310</code> shows everything known about it: claims, tasks, reviews, notes and experts.</div>
<div class="msgs" id="results"></div>
</div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-pins">
<header><h2 id="h-pins"><svg class="i"><use href="static/icons.svg#pin"/></svg>Pinned and decisions</h2></header>
<div class="body"><div class="msgs" id="pins"></div></div>
</section>
<section class="card" aria-labelledby="h-threads">
<header><h2 id="h-threads"><svg class="i"><use href="static/icons.svg#thread"/></svg>Threads</h2><span class="grow"></span><label class="switch"><input type="checkbox" id="archive"> Chat archive</label></header>
<div class="body"><ul class="threads" id="threads"></ul></div>
</section>
</div>
</section>
<!-- logbook -->
<section class="view lb" id="view-logbook" aria-label="Logbook">
<section class="card"><div class="body lb-head">
<div><span class="chip" id="lbMilestone">Milestone</span>
<p>Decompiling Sid Meier’s Pirates! (2004) into readable C, then rebuilding it in Rust, charted function by function against the PC 1.0.2 executable.</p></div>
<div class="lb-stamp" id="lbStamp">Loading…</div></div></section>
<section class="card" aria-labelledby="h-pm" id="pmCard">
<div class="body">
<h2 class="pm-title" id="h-pm"><span id="pmProject">The project</span> is <b id="pmPct">–</b> decompiled</h2>
<div class="pm-bars" id="pmBars"></div>
<div class="pm-commit"><span id="pmCommit"></span><span class="grow"></span>
<button class="btn sm" id="pmPrev" type="button">Previous</button><button class="btn sm" id="pmNext" type="button">Next</button><button class="btn sm" id="pmLatest" type="button">Latest</button></div>
<div class="note" id="pmHistNote" style="margin-top:6px"></div>
<div class="pm-tools"><select id="pmUnits" aria-label="Jump to a unit"></select>
<input type="search" id="pmFilter" placeholder="Filter: name, &lt;70%, &gt;10kb" aria-label="Filter units"><span class="note" id="pmCount"></span></div>
<div class="pm-crumb" id="pmCrumb"></div>
<div class="pm-wrap"><canvas id="pmMap" aria-label="Treemap of units, area by code bytes, colour by share decompiled"></canvas></div>
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--pm-exact)"></i>exact match</span><span><i class="lb-sw" style="background:var(--pm-verified)"></i>verified in the emulator</span><span><i class="lb-sw" style="background:var(--pm-readable)"></i>readable</span><span><i class="lb-sw" style="background:var(--pm-raw)"></i>raw</span><span><i class="lb-sw" style="background:var(--pm-deferred)"></i>deferred</span><span><i class="lb-sw" style="background:var(--pm-unsorted)"></i>unsorted (not counted)</span><span><i class="lb-sw" style="background:var(--pm-engine)"></i>engine and libraries: named, not decompiled (not counted)</span></div>
<div class="note" style="margin-top:8px">A unit is an original source file (from the Xbox build’s asserts) or, without one, a 64 KB address block. Area is code bytes; a unit’s green is the share of its game-code bytes decompiled. Click a unit to see its functions.</div>
<div class="pm-tip" id="pmTip" hidden></div>
</div>
</section>
<div class="lb-figs" id="lbFigs"></div>
<section class="card" aria-labelledby="h-lbmap">
<header><h2 id="h-lbmap"><svg class="i"><use href="static/icons.svg#layout"/></svg>Chart of the executable</h2><span class="note">bytes per category along the code addresses</span></header>
<div class="body"><canvas id="lbMap" aria-label="Address space of the executable, bytes per category"></canvas>
<div class="lb-axis"><span id="lbLow">0x401000</span><span>code address →</span><span id="lbHigh"></span></div>
<div class="lb-legend" id="lbLegend"></div></div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-lbwhere">
<header><h2 id="h-lbwhere"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Where the code stands</h2></header>
<div class="body" id="lbWhere"></div>
</section>
<section class="card" aria-labelledby="h-lbtrend">
<header><h2 id="h-lbtrend"><svg class="i"><use href="static/icons.svg#activity"/></svg>Trend</h2></header>
<div class="body"><svg id="lbTrend" viewBox="0 0 300 110" preserveAspectRatio="none" aria-label="Progress over time"></svg>
<div class="lb-legend"><span><i class="lb-sw" style="background:var(--lb-game)"></i>classified</span><span><i class="lb-sw" style="background:var(--lb-platform)"></i>game code decompiled</span><span><i class="lb-sw" style="background:var(--lb-done)"></i>done (functions handled)</span></div>
<div class="note" id="lbTrendNote" style="margin-top:8px"></div></div>
</section>
</div>
<div class="cols">
<section class="card" aria-labelledby="h-lbms">
<header><h2 id="h-lbms"><svg class="i"><use href="static/icons.svg#flag"/></svg>Milestones</h2></header>
<div class="body" id="lbMilestones"></div>
</section>
<section class="card" aria-labelledby="h-lblog">
<header><h2 id="h-lblog"><svg class="i"><use href="static/icons.svg#book"/></svg>Ship’s log</h2><span class="count" id="lbLogCount"></span></header>
<div class="body"><ul class="lb-log" id="lbLog"></ul></div>
</section>
</div>
</section>
<!-- decomp -->
<section class="view" id="view-decomp" aria-label="Decomp">
<section class="card" aria-labelledby="h-progress">
<header><h2 id="h-progress"><svg class="i"><use href="static/icons.svg#trophy"/></svg>Progress by function</h2><span class="count" id="progressSum"></span><span class="grow"></span>
<button class="btn sm" id="refreshDecomp"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Refresh</button></header>
<div class="body" id="progress"></div>
</section>
<section class="card" aria-labelledby="h-inspect">
<header><h2 id="h-inspect"><svg class="i"><use href="static/icons.svg#search"/></svg>Inspect a function</h2><span class="note">original name, source file, convention, match and near-duplicates</span></header>
<div class="body">
<form class="searchbar" id="fnForm"><div class="field"><svg class="i"><use href="static/icons.svg#search"/></svg>
<input type="search" id="fnTarget" placeholder="An address (0x4ADC70) or a name (CanCarry)" aria-label="Function"></div>
<button class="btn primary">Inspect</button></form>
<div id="fnInfo"></div>
</div>
</section>
<div class="cols">
<section class="card" aria-labelledby="h-next">
<header><h2 id="h-next"><svg class="i"><use href="static/icons.svg#list"/></svg>Next up in each band</h2><span class="note">what next_functions hands out, easiest first</span></header>
<div class="body flush scroll" id="nextUp"></div>
</section>
<section class="card" aria-labelledby="h-deferred">
<header><h2 id="h-deferred"><svg class="i"><use href="static/icons.svg#clock"/></svg>Deferred</h2><span class="count" id="deferCount"></span></header>
<div class="body flush scroll" id="deferred"></div>
</section>
</div>
</section>
<!-- health -->
<section class="view" id="view-health" aria-label="Health">
<section class="card" aria-labelledby="h-notify">
<header><h2 id="h-notify"><svg class="i"><use href="static/icons.svg#bell"/></svg>Notifications</h2></header>
<div class="body"><label class="switch" title="Windows notifications for questions, urgent messages and messages to you - also with this page closed"><input type="checkbox" id="toasts"> Windows notifications for questions, urgent messages and messages to you, also while this page is closed</label></div>
</section>
<section class="card" aria-labelledby="h-retools">
<header><h2 id="h-retools"><svg class="i"><use href="static/icons.svg#terminal"/></svg>Reverse-engineering tools</h2><span class="count" id="reSum"></span></header>
<div class="body flush scroll" id="retools"></div>
</section>
<section class="card" aria-labelledby="h-health">
<header><h2 id="h-health"><svg class="i"><use href="static/icons.svg#heart"/></svg>Health and connections</h2><span class="count" id="healthSum"></span><span class="grow"></span>
<button class="btn sm primary" id="runDoctor"><svg class="i sm"><use href="static/icons.svg#refresh"/></svg>Run checks</button></header>
<div class="body flush scroll" id="health"></div>
</section>
</section>
</main>
</div>
</div>
<dialog id="availDialog" aria-labelledby="availTitle">
<form id="availForm">
<header><h2 id="availTitle">Your availability</h2><button type="button" class="btn icon sm ghost" id="availClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
<div class="body">
<div class="segmented" role="radiogroup" aria-label="Status">
<label><input type="radio" name="st" value="here" checked><span>Here</span></label>
<label><input type="radio" name="st" value="away"><span>Away</span></label>
<label><input type="radio" name="st" value="dnd"><span>Do not disturb</span></label>
</div>
<label class="field-label" for="availUntil">Until</label>
<select id="availUntil"><option value="">No end time</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="tomorrow">Tomorrow 8 AM</option></select>
<label class="field-label" for="availNote">Note for the team</label>
<input type="text" id="availNote" placeholder="e.g. decide with Claude, don't wait for me">
</div>
<footer><span class="grow note">Agents see this at the top of every brief.</span><button class="btn primary">Save</button></footer>
</form>
</dialog>
<dialog id="promptDialog" aria-labelledby="promptTitle">
<header><h2 id="promptTitle">Restart prompt</h2><button class="btn sm" id="promptCopy"><svg class="i sm"><use href="static/icons.svg#copy"/></svg>Copy</button><button class="btn icon sm ghost" id="promptClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
<div class="body"><textarea id="promptText" readonly aria-label="Prompt"></textarea></div>
<footer><span class="grow note" id="promptNote">Paste this into a fresh session for the agent.</span></footer>
</dialog>
<dialog id="threadDialog" aria-labelledby="threadTitle">
<header><h2 id="threadTitle">Thread</h2>
<button class="btn sm" id="threadToChat" title="Open this thread in the chat"><svg class="i sm"><use href="static/icons.svg#chat"/></svg>Open in chat</button>
<button class="btn sm" id="exportThread" title="Download the thread as Markdown"><svg class="i sm"><use href="static/icons.svg#download"/></svg>Export</button>
<button class="btn sm" id="settleThread" title="Mark the thread settled">Close thread</button>
<button class="btn icon sm ghost" id="closeThread" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
<div class="body"><div class="msgs" id="threadMsgs"></div></div>
<form id="replyForm"><footer style="flex-direction:column;align-items:stretch">
<textarea id="replyText" placeholder="Reply in this thread" aria-label="Reply" style="min-height:64px"></textarea>
<div class="row"><span class="note grow" id="replyNote"></span>
<label class="check"><input type="checkbox" id="replyAck"> ask for acknowledgement</label>
<select id="replyImportance" aria-label="Importance"><option>normal</option><option>high</option><option>urgent</option></select>
<button class="btn primary"><svg class="i sm"><use href="static/icons.svg#send"/></svg>Send</button></div>
</footer></form>
</dialog>
<dialog id="catchup" aria-labelledby="catchTitle">
<header><h2 id="catchTitle">While you were away</h2><button type="button" class="btn icon sm ghost" id="catchClose" aria-label="Close"><svg class="i"><use href="static/icons.svg#x"/></svg></button></header>
<div class="body" id="catchBody"></div>
<footer><button type="button" class="btn primary" id="askStatus" title="Ask every running agent for a status reply"><svg class="i sm"><use href="static/icons.svg#chat"/></svg>Ask the agents for a status</button></footer>
</dialog>
<script src="static/themes.js"></script>
<script>
let TOKEN=(document.querySelector('meta[name=harbor-token]')||{}).content||'';
// the server makes a new token each start: a page left open across a restart fetches it again instead of failing
async function freshToken(){const r=await fetch('./',{cache:'no-store'});const m=(await r.text()).match(/name="harbor-token" content="([^"]+)"/);if(m)TOKEN=m[1];return !!m}
const el=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const store={get(k,d){try{const v=localStorage.getItem('harbor.'+k);return v===null?d:JSON.parse(v)}catch(e){return d}},set(k,v){try{localStorage.setItem('harbor.'+k,JSON.stringify(v))}catch(e){}}};
const ic=(n,c)=>'<svg class="i'+(c?' '+c:'')+'" aria-hidden="true"><use href="static/icons.svg#'+n+'"></use></svg>';
const empty=(icon,title,text)=>'<div class="empty">'+ic(icon)+'<strong>'+esc(title)+'</strong><span>'+(text||'')+'</span></div>';
const INIT={Claude:'CL',Codex:'CX',DeepSeek:'DS',OpenCode:'OC',Kimi:'KM',User:'You'};
const ago=m=>m==null?'never':m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago';
const central=s=>{const d=new Date(s);return isNaN(d)?s:d.toLocaleString('en-US',{timeZone:'America/Chicago',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',timeZoneName:'short'})};
const STATE={submitted:'blue',in_review:'violet',changes_requested:'amber',approved:'green',held:'',landed:'green',withdrawn:'',rejected:'red'};
let data=null,openThread=null,current=null,doctorRan=false,unseen=0;
const HUMAN=()=>data&&data.project?data.project.human:'User';
function avatar(n,size,state){const a=(data&&data.agents.find(x=>x.name===n))||{};
return '<div class="avatar'+(size?' '+size:'')+' '+esc(String(n).toLowerCase())+'"'+(a.color?' style="--avatar:'+esc(a.color)+'"':'')+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(a.initials||INIT[n]||String(n).slice(0,2))+'</div>'}
function put(id,html){const e=typeof id==='string'?el(id):id;if(e._html===html)return false;
const open=[...e.querySelectorAll('details[open][data-k]')].map(d=>d.dataset.k);e._html=html;e.innerHTML=html;
open.forEach(k=>{const d=e.querySelector('details[data-k="'+CSS.escape(k)+'"]');if(d)d.open=true});return true}
async function post(path,body,again){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Harbor-Token':TOKEN},body:JSON.stringify(body)});const j=await r.json().catch(()=>({}));if(r.status===403&&/stale page/.test(j.error||'')&&!again&&await freshToken())return post(path,body,true);if(!r.ok||j.ok===false)throw new Error(j.error||('HTTP '+r.status));return j}
async function get(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json();if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
/* Run fn every ms while the page is visible and every hiddenMs while it is hidden (null: not at all). The next run is
timed from the end of the last one, so slow requests never pile up; showing the page again runs everything at once. */
const LOOPS=[];
function every(fn,ms,hiddenMs){const L={busy:false,t:0};
L.run=async()=>{clearTimeout(L.t);if(L.busy)return;
if(!document.hidden||hiddenMs){L.busy=true;try{await fn()}catch(e){}L.busy=false}
L.t=setTimeout(L.run,document.hidden?(hiddenMs||5000):ms)};
L.t=setTimeout(L.run,document.hidden&&hiddenMs?hiddenMs:ms);LOOPS.push(L);return L}
document.addEventListener('visibilitychange',()=>{if(!document.hidden)LOOPS.forEach(L=>{if(!L.busy)L.run()})});
const VIEWS={overview:['Overview','Who is working, what needs you, and what happened','home'],launch:['Launch','Bring agents online one careful step at a time','rocket'],
tasks:['Tasks','The shared queue that idle agents take work from','tasks'],review:['Review','Submitted work, acknowledgements and claims','review'],
activity:['Activity','Live tool calls, the team pulse and the scoreboard','activity'],knowledge:['Knowledge','Search every message, pinned decisions and threads','book'],
health:['Health','Harbor in every agent app, the database, backups and the server','heart'],
decomp:['Decomp','Progress by function, the function inspector and what each agent takes next','terminal'],
logbook:['Logbook','Where the decomp stands, how it got here and what comes next','anchor'],chat:['Chat','The team’s conversation','chat']};
function show(view){if(!VIEWS[view])view='overview';current=view;
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('on',v.id==='view-'+view));
document.querySelectorAll('.nav-btn[data-go]').forEach(b=>{if(b.dataset.go===view)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current')});
el('content').classList.toggle('flat',view==='chat');
if(view!=='chat'){el('viewTitle').textContent=VIEWS[view][0];el('viewSub').textContent=VIEWS[view][1];const vi=el('viewIcon');vi.className='view-icon';put(vi,ic(VIEWS[view][2]))}
paintTitle();
if(view!=='chat')store.set('board.view',view);if(location.hash.slice(1)!==view)location.hash=view;
el('shell').classList.remove('open');el('content').scrollTop=0;
Chat.view();
if(data&&view==='overview')renderDigest();
if(data&&view==='activity'){renderPulse();renderFeed()}
if(data&&view==='launch')renderLaunch();
if(data&&view==='decomp')renderDecomp();
if(data&&view==='logbook')renderLogbook();
if(view==='health')renderTools();
if(view==='health'&&!doctorRan)runDoctor()}
function paintTitle(){document.title=(unseen&&document.hidden?'('+unseen+') ':'')+(data?data.project.title+' · ':'')+(current?VIEWS[current][0]:'Harbor')}
function agentState(a){return a.stopped.length?'stopped':a.trial_until?'trial':a.working?'working':a.idle_minutes!=null&&a.idle_minutes<15?'online':'idle'}
function renderControl(){
const all=data.flags.find(f=>f.key==='stop:all');const ais=data.agents.filter(a=>a.kind!=='human');
const nStopped=ais.filter(a=>a.stopped.length).length;const stopped=ais.length>0&&nStopped===ais.length;
el('banner').className='hero'+(stopped?' stopped':nStopped?' partial':'');
put('heroIcon',ic(stopped?'stop':nStopped?'alert':'play'));
el('bannerTitle').textContent=stopped?'Everyone is stopped':nStopped?nStopped+' of '+ais.length+' stopped':'Everyone may work';
el('bannerNote').textContent=all?((stopped?'Stopped':'Resumed')+' '+central(all.set_at)+' by '+(all.set_by===HUMAN()?'you':all.set_by)+(all.reason?' · '+all.reason:'')):'Workers check this at the start of every session (harbor brief).';
el('stopAll').disabled=stopped;el('resumeAll').disabled=!nStopped;
el('flagNote').textContent='Only you can resume. An agent can stop itself.';
const working=ais.filter(a=>a.working).length;
el('crewCount').textContent=ais.length+' agents'+(working?' · '+working+' working':'');
put('agents',ais.map(a=>{
const own=a.stopped.length>0,state=agentState(a);
const why=own&&a.stopped[0]&&a.stopped[0].reason?' title="'+esc(a.stopped[0].reason)+'"':'';
const st=a.trial_until&&!own?'<span class="tag amber"><span class="dot"></span>trial until '+esc(central(a.trial_until))+'</span>':own?'<span class="tag red"'+why+'><span class="dot"></span>stopped</span>':'<span class="tag green"><span class="dot"></span>may work</span>';
const unread=a.unread?'<span class="tag'+(a.urgent?' red':' blue')+'">'+a.unread+' unread'+(a.urgent?', '+a.urgent+' urgent':'')+'</span>':'';
return '<article class="agent '+state+'"><div class="agent-top">'+avatar(a.name,'lg',state)+'<div class="grow"><div class="name">'+esc(a.name)+'</div><div class="role">'+esc(a.role)+' · '+(a.idle_minutes==null?'not seen yet':'active '+esc(ago(a.idle_minutes)))+'</div></div>'+st+'</div>'+
((a.working||a.doing)?'<div class="agent-now">'+(a.working?'<div class="working">Working'+(a.last_tool?' · '+esc(a.last_tool):'')+'</div>':'')+(a.doing?'<div class="doing">'+esc(a.doing)+' <span class="when">· '+esc(ago(a.doing_minutes))+'</span></div>':'')+'</div>':'')+
gitLine(a.name)+linkBadges(a)+
(a.handoff?'<details class="handoff" data-k="'+esc(a.name)+'"><summary>Last handoff · '+esc(ago(a.handoff.minutes))+'</summary><pre>'+esc(a.handoff.text)+'</pre></details>':'')+
'<div class="agent-actions">'+(own?'<button class="btn sm go" data-resume="'+esc(a.name)+'">'+ic('play','sm')+'Resume</button>':'<button class="btn sm" data-stop="'+esc(a.name)+'">'+ic('stop','sm')+'Stop</button>')+
'<button class="btn sm" data-ping="'+esc(a.name)+'" title="Send an urgent are-you-there that asks for an acknowledgement">'+ic('bell','sm')+'Ping</button><button class="btn sm" data-prompt="'+esc(a.name)+'" title="A prompt to paste into a fresh session">'+ic('terminal','sm')+'Prompt</button>'+unread+'</div></article>'}).join(''));
}
function gitLine(name){const g=(data.git||{})[name];if(!g)return '';if(g.error)return '<div class="gitline gitwarn" title="'+esc(g.error)+'">'+ic('git','sm')+'git unavailable</div>';
const lc=g.last_commit||{};const parts=['<b>'+esc(g.branch)+'</b>',esc(lc.sha||'')+' '+esc(ago(lc.at?Math.round((Date.now()-new Date(lc.at))/60000):null))];
if(g.dirty)parts.push('<span class="gitwarn">'+g.dirty+' uncommitted</span>');if(g.behind_main)parts.push(g.behind_main+' behind main');
const u=(g.unsubmitted||[]).length;if(u)parts.push('<span class="gitwarn">'+u+(u>=50?'+':'')+' not submitted</span>');
return '<div class="gitline" title="'+esc(lc.subject||'')+'">'+ic('git','sm')+parts.map(p=>'<span>'+p+'</span>').join('')+'</div>'}
function linkBadges(a){
const c=a.connections||[];if(!c.length)return '<div class="links"><span class="tag" title="Harbor has not seen this agent through its app yet">not connected yet</span></div>';
return '<div class="links">'+c.map(x=>'<span class="tag '+(x.minutes<15?'green':'')+'" title="'+x.count+' calls">'+esc(x.channel)+(x.host?' · '+esc(x.host):'')+' · '+esc(ago(x.minutes))+'</span>').join('')+'</div>'}
function renderStats(){
const ais=data.agents.filter(a=>a.kind!=='human');const working=ais.filter(a=>a.working).length,stopped=ais.filter(a=>a.stopped.length).length;
const progress=data.tasks.filter(t=>t.state==='in_progress').length,acks=new Set(data.pending_acks.map(r=>r.id)).size,wins=(data.wins||[]).length;
const tiles=[['activity','Working now',working,'of '+ais.length+' agents','activity',working?'green':'quiet'],
['stop','Stopped',stopped,stopped?'resume them on Launch':'everyone may work','launch',stopped?'red':'quiet'],
['tasks','Open tasks',data.tasks.length,progress+' in progress','tasks','blue'],
['review','In review',data.queue.length,acks?acks+' waiting for an ack':'review queue','review','violet'],
['trophy','Landed today',wins,'last 24 hours','activity',wins?'amber':'quiet']];
put('stats',tiles.map(([i,label,v,sub,go,tone])=>'<button class="stat '+tone+'" data-go="'+go+'"><span class="stat-icon">'+ic(i)+'</span><span><span class="stat-v">'+v+'</span><span class="stat-l">'+esc(label)+'</span><span class="stat-s">'+esc(sub)+'</span></span></button>').join(''));
const badge=(id,n,cls)=>{const b=el(id);b.textContent=n||'';b.className='count-badge'+(cls?' '+cls:'');b.hidden=!n};
const q=(data.questions||[]).length;
badge('navOverview',q+(data.attention||[]).length,q?'hot':'');badge('navLaunch',stopped,'');badge('navTasks',data.tasks.length,'');
badge('navReview',data.queue.length,'accent');badge('navActivity',working,'green');badge('navKnowledge',(data.pins||[]).length,'');
const me=data.agents.find(a=>a.name===HUMAN());badge('chatUnread',me&&me.unread||0,me&&me.urgent?'hot':'accent');
}
function step(done,label,title,wait){return '<span class="step'+(done?' done':wait?' wait':'')+'" title="'+esc(title||'')+'"><i>'+(done?'✓':wait?'…':'·')+'</i>'+esc(label)+'</span>'}
async function renderLaunch(){
let L;try{L=await get('api/launch')}catch(e){return}
const rows=L.agents.map(a=>{const rc=a.rollcall;const stateTxt=a.state==='stopped'?'stopped':a.state==='trial'?'trial until '+central(a.trial_until):'running';
return '<tr><td><div class="who">'+avatar(a.agent,'sm',a.state==='stopped'?'stopped':a.state==='trial'?'trial':a.working?'working':a.connected?'online':'')+'<div><strong>'+esc(a.agent)+'</strong><div class="note">'+esc(a.host||'')+'</div></div></div></td>'+
'<td>'+step(a.connected,'app connected',a.connected_via.join(', ')||'not seen through MCP or hooks yet')+'</td>'+
'<td>'+step(!!a.hooks,'stop gate',a.hooks?'hooks reported in '+central(a.hooks):'the app has not run Harbor hooks yet: a stop is not enforced for this agent and the live feed cannot see its work',a.connected&&!a.hooks)+'</td>'+
'<td>'+step(!!a.brief_at,'read brief',a.brief_at?central(a.brief_at):'not yet')+'</td>'+
'<td>'+step(rc&&rc.answer,'roll call',rc?(rc.answer?'answered in '+rc.seconds+' s':rc.read_at?'read, not answered yet':'not read yet'):'not asked',rc&&!rc.answer)+'</td>'+
'<td>'+step(a.state!=='stopped',stateTxt,a.stop_reason||'',a.state==='trial')+(a.working?' <span class="working">working</span>':'')+'</td>'+
'<td><div class="acts"><button class="btn sm" data-prompt="'+esc(a.agent)+'">'+ic('terminal','sm')+'Prompt</button><button class="btn sm" data-rollcall="'+esc(a.agent)+'">Roll call</button>'+
(a.state==='stopped'?'<select data-trialsel="'+esc(a.agent)+'" aria-label="Trial length"><option value="15m">15 min</option><option value="30m" selected>30 min</option><option value="1h">1 hour</option></select><button class="btn sm" data-trial="'+esc(a.agent)+'">Trial</button><button class="btn sm go" data-resume="'+esc(a.agent)+'">'+ic('play','sm')+'Resume</button>'
:'<button class="btn sm" data-stop="'+esc(a.agent)+'">'+ic('stop','sm')+'Stop</button>')+'</div></td></tr>'}).join('');
const keep={};document.querySelectorAll('[data-trialsel]').forEach(s=>keep[s.dataset.trialsel]=s.value);
const html='<table class="launch"><thead><tr><th>Agent</th><th>Connected</th><th>Hooks</th><th>Brief</th><th>Roll call</th><th>State</th><th></th></tr></thead><tbody>'+rows+'</tbody></table>'+(L.rollcall?'<div class="note">Last roll call '+esc(central(L.rollcall.at))+': '+L.rollcall.agents.filter(x=>x.answer).length+' of '+L.rollcall.agents.length+' answered.</div>':'');
if(html===renderLaunch.last||document.activeElement&&document.activeElement.matches('[data-trialsel]'))return;
renderLaunch.last=html;el('launch').innerHTML=html;
document.querySelectorAll('[data-trialsel]').forEach(s=>{if(keep[s.dataset.trialsel])s.value=keep[s.dataset.trialsel]});
}
async function renderFeed(){let f;try{f=await get('api/feed?limit=60')}catch(e){return}
put('feed',f.length?f.map(r=>'<li><span class="t">'+esc(r.seconds<60?r.seconds+' s':Math.round(r.seconds/60)+' min')+'</span><span class="who">'+avatar(r.agent,'xs')+esc(r.agent)+'</span><span class="tool">'+(r.tool?'<span class="tag">'+esc(r.tool)+'</span>':'')+'</span><span class="tg" title="'+esc(r.target||'')+'">'+esc(r.target||'')+'</span></li>').join('')
:'<li class="empty">'+ic('zap')+'<strong>No tool calls yet</strong><span>They appear here as soon as an agent works with Harbor\'s hooks installed.</span></li>')}
const PRIO={0:'urgent',1:'high',2:'normal',3:'low'};
let tasksCache=[];
async function renderTasks(){
const rows=el('tasksAll').checked?await get('api/tasks?all=1'):data.tasks;tasksCache=rows;
const open=data.tasks.length;el('taskCount').textContent=open?open+' open':'';
put('tasks',rows.length?'<table><thead><tr><th>#</th><th>Task</th><th>State</th><th>Who</th><th>Priority</th><th></th></tr></thead><tbody>'+rows.map(t=>'<tr><td class="mono">'+t.id+'</td><td>'+esc(t.title)+(t.scope?'<div class="note mono">'+esc(t.scope)+'</div>':'')+(t.waiting_on.length?'<div class="note">after '+t.waiting_on.map(d=>'#'+d).join(', ')+'</div>':'')+(t.state==='blocked'?'<div class="note err">blocked: '+esc(t.block_reason||'')+'</div>':'')+(t.item_id?'<div class="note">review item #'+t.item_id+'</div>':'')+'</td><td><span class="tag '+({open:'blue',in_progress:'violet',blocked:'red',review:'amber',done:'green'}[t.state]||'')+'">'+esc(t.state.replace('_',' '))+'</span></td><td>'+(t.assignee?'<span class="cell-who">'+avatar(t.assignee,'xs')+esc(t.assignee)+'</span>':'<span class="muted">anyone</span>')+'</td><td><span class="prio prio-'+esc(t.priority_name)+'">'+esc(t.priority_name)+'</span></td><td style="text-align:right">'+(['done','cancelled'].includes(t.state)?'<button class="btn sm" data-task="'+t.id+'" data-to="open">Reopen</button>':'<button class="btn sm ghost" data-task="'+t.id+'" data-to="cancelled">Cancel</button>')+'</td></tr>').join('')+'</tbody></table>'
:empty('tasks','No tasks','Add one above; idle agents take the best open task with <code>next</code>.'));
const opts=data.agents.filter(a=>a.kind!=='human').map(a=>a.name);const sel=el('tAssignee');if(sel.options.length!==opts.length+1){sel.replaceChildren(new Option('Anyone (next)',''),...opts.map(n=>new Option(n,n)))}
}
function renderPins(){
const p=data.pins||[];put('pins',p.length?p.map(msgHtml).join(''):empty('pin','Nothing pinned','Agents pin rules and decisions with <code>pin</code>; you can pin from the chat.'));
}
const PULSE_STEP=5*60000;
async function renderPulse(){
const hours=Number(el('pulseHours').value);let d;try{d=await get('api/pulse?hours='+hours)}catch(e){return}
const end=Date.now(),start=end-hours*3600000,cells=Math.ceil(hours*12);
const agents=data.agents.filter(a=>a.kind!=='human');
const rows=agents.map(a=>{const series=d.agents[a.name]||[];const counts=new Array(cells).fill(0);
for(const b of series){const i=Math.floor((new Date(b.bucket)-start)/PULSE_STEP);if(i>=0&&i<cells)counts[i]+=b.tools+b.messages}
const peak=Math.max(1,...counts);const w=100/cells;let total=counts.reduce((x,y)=>x+y,0);
const rects=counts.map((c,i)=>c?'<rect x="'+(i*w).toFixed(3)+'" y="0" width="'+(w+0.05).toFixed(3)+'" height="18" fill="'+esc(a.color||'#4688e6')+'" fill-opacity="'+(0.25+0.75*c/peak).toFixed(2)+'"><title>'+esc(central(new Date(start+i*PULSE_STEP).toISOString()))+': '+c+'</title></rect>':'').join('');
const idle=series.length?Math.round((end-new Date(series[series.length-1].bucket))/60000):null;
return '<div class="pulse-row"><div class="who">'+avatar(a.name,'xs')+esc(a.name)+'</div><div class="bar"><svg viewBox="0 0 100 18" preserveAspectRatio="none" role="img" aria-label="'+esc(a.name)+' activity">'+rects+'</svg></div><div class="note">'+(total?(idle!=null&&idle<10?'<span class="working">now</span>':'idle '+esc(ago(idle))):'no activity')+'</div></div>'}).join('');
const t0=new Date(start),tm=new Date(start+hours*1800000);
put('pulse',rows+'<div class="pulse-axis"><div></div><div><span>'+esc(central(t0.toISOString()))+'</span><span>'+esc(central(tm.toISOString()))+'</span><span>now</span></div><div></div></div>');
}
function sparkline(series){if(series.length<2)return '<span></span>';const v=series.map(s=>s.value),lo=Math.min(...v),hi=Math.max(...v),span=hi-lo||1;
const pts=v.map((x,i)=>(i*100/(v.length-1)).toFixed(1)+','+(28-((x-lo)/span)*26).toFixed(1)).join(' ');
return '<svg class="spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><polygon points="0,30 '+pts+' 100,30" style="fill:var(--accent);fill-opacity:.12"/><polyline points="'+pts+'" style="fill:none;stroke:var(--accent)" stroke-width="1.8" stroke-linejoin="round" vector-effect="non-scaling-stroke"/></svg>'}
function renderScore(){
const m=data.metrics||{};const names=Object.keys(m);
put('metrics',names.length?names.map(n=>{const s=m[n].series,l=m[n].latest,first=s[0].value,diff=l.value-first;
return '<div class="metric"><div><div class="v">'+esc(Number(l.value).toLocaleString())+'</div><div class="note">'+esc(n)+' · '+esc(l.by_agent)+' · '+esc(ago(Math.round((Date.now()-new Date(l.at))/60000)))+'</div></div>'+sparkline(s)+'<div class="d'+(diff<0?' neg':'')+'">'+(diff?(diff>0?'+':'')+esc(Number(diff.toFixed(2)).toLocaleString()):'')+'</div></div>'}).join('')
:empty('activity','No metrics yet','Agents report numbers with <code>metric readable 779</code>.'));
const w=data.wins||[];el('winCount').textContent=w.length?w.length+' landed today':'';
put('wins',w.length?w.map(x=>'<li><span class="tag green">'+ic('check','sm')+'landed</span><span>'+esc(x.title)+' <span class="note">by '+esc(x.author)+' · '+esc(central(x.at))+'</span></span></li>').join(''):'<li class="note">Nothing landed in the last 24 hours.</li>');
}
function renderQuestions(){
const q=data.questions||[];el('questionsCard').hidden=!q.length;el('questionCount').textContent=q.length?q.length+' waiting':'';
put('questions',q.map(x=>'<div class="qitem">'+avatar(x.asker,'sm')+'<div class="grow"><div class="who"><strong>'+esc(x.asker)+'</strong> asks</div><div class="qt">'+esc(x.question)+'</div><div class="row">'+x.options.map(o=>'<button class="btn sm" data-answer="'+x.id+'" data-value="'+esc(o)+'">'+esc(o)+'</button>').join('')+'<button class="btn sm ghost" data-answer="'+x.id+'" data-value="">'+(x.options.length?'Other…':'Answer…')+'</button></div></div></div>').join(''));
}
function renderAvail(){
const s=data.presence||{status:'here'};const b=el('availBtn');b.className='avail '+(s.status==='here'?'':s.status);
b.textContent=s.status==='here'?'Here':(s.status==='away'?'Away':'Do not disturb')+(s.until?' until '+central(s.until):'');
}
function renderAttention(){
const a=data.attention||[];el('attentionCard').hidden=!a.length;el('attentionCount').textContent=a.length?a.length+' item'+(a.length>1?'s':''):'';
const label={unsubmitted:'Not submitted',old_base:'Old base',busy_no_progress:'Maybe stuck',collision:'Same file',questions:'Questions',ack_overdue:'Ack overdue',item_stale:'Review waiting',agent_quiet:'Quiet',due_overdue:'Answer overdue',task_overdue:'Task overdue',task_blocked:'Blocked',human_away:'While you are away',no_hooks:'No stop gate',resting:'Resting',unread_questions:'Unanswered'};
put('attention',a.map(x=>'<li><span class="tag amber">'+esc(label[x.kind]||x.kind)+'</span><span>'+esc(x.text)+'</span></li>').join(''));
}
async function renderDigest(){
try{const d=await get('api/digest?since='+encodeURIComponent(el('since').value));
const active=d.agents.filter(a=>a.messages||a.submitted||a.reviewed||a.landed||a.claims);
let h=active.length?'<table><thead><tr><th>Who</th><th>Messages</th><th>Submitted</th><th>Reviewed</th><th>Landed</th><th>Claims</th><th>Doing now</th></tr></thead><tbody>'+active.map(a=>'<tr><td><span class="cell-who">'+avatar(a.name,'xs')+esc(a.name)+'</span></td><td>'+a.messages+' in '+a.threads+' thread'+(a.threads===1?'':'s')+'</td><td>'+a.submitted+'</td><td>'+a.reviewed+'</td><td>'+a.landed+'</td><td>'+a.claims+'</td><td>'+esc(a.doing||'')+'</td></tr>').join('')+'</tbody></table>':empty('clock','A quiet stretch','Nobody did anything in this window.');
const ev=d.item_events.map(e=>'<li><span class="tag violet">#'+e.item_id+'</span><span>'+esc(e.title)+': '+esc(e.from_state||'new')+' → '+esc(e.to_state)+' by '+esc(e.agent)+'</span></li>').concat(d.flags.map(f=>'<li><span class="tag '+(f.value==='on'?'red':'green')+'">'+esc(f.key)+' '+esc(f.value)+'</span><span>by '+esc(f.set_by)+(f.reason?' · '+esc(f.reason):'')+'</span></li>'));
if(ev.length)h+='<h3 class="sub-h">Review and control changes</h3><ul class="attn">'+ev.join('')+'</ul>';
if(d.to_human.length)h+='<h3 class="sub-h">Messages to you</h3><div class="msgs">'+d.to_human.map(msgHtml).join('')+'</div>';
put('digest',h)}catch(e){put('digest','<div class="err">'+esc(e.message)+'</div>')}
}
function renderProjects(){
el('boardTitle').textContent=data.project.title;paintTitle();
const s=el('projectSelect');if(data.projects.length<2){s.hidden=true;return}s.hidden=false;
if(s.options.length!==data.projects.length)s.replaceChildren(...data.projects.map(n=>new Option(n,n)));s.value=data.project.name;
}
function renderQueue(){
const q=data.queue;el('queueCount').textContent=q.length?q.length+' open':'';
put('queue',q.length?'<table><thead><tr><th>#</th><th>Item</th><th>Author</th><th>State</th><th>Waiting</th></tr></thead><tbody>'+q.map(i=>'<tr class="click" data-thread="'+esc(i.thread_id||'')+'"><td class="mono">'+i.id+'</td><td>'+esc(i.title)+(i.scope?'<div class="note mono">'+esc(i.scope)+'</div>':'')+(i.commit_hash?'<div class="note mono">'+esc(String(i.commit_hash).slice(0,12))+(i.branch?' on '+esc(i.branch):'')+'</div>':'')+'</td><td><span class="cell-who">'+avatar(i.author,'xs')+esc(i.author)+'</span></td><td><span class="tag '+(STATE[i.state]||'')+'">'+esc(i.state.replace('_',' '))+'</span>'+(i.assignee?'<div class="note">'+esc(i.assignee)+'</div>':'')+'</td><td>'+esc(ago(i.idle_minutes))+'<div class="note">opened '+esc(ago(i.age_minutes))+'</div></td></tr>').join('')+'</tbody></table>'
:empty('review','Nothing waiting for review','Submitted work shows up here with its commit and reviewer.'));
}
function renderAcks(){
const groups=[],byId={};
data.pending_acks.forEach(r=>{let g=byId[r.id];if(!g){g=byId[r.id]={id:r.id,sender:r.sender,body:r.body,age:r.age_minutes,agents:[]};groups.push(g)}g.agents.push(r.agent)});
el('ackCount').textContent=groups.length?groups.length+' message'+(groups.length>1?'s':''):'';
put('acks',groups.length?'<table><thead><tr><th>#</th><th>From</th><th>Waiting on</th><th>Message</th><th>Sent</th></tr></thead><tbody>'+groups.map(g=>'<tr><td class="mono">'+g.id+'</td><td style="white-space:nowrap">'+esc(g.sender===HUMAN()?'you':g.sender)+'</td><td>'+esc(g.agents.map(n=>n===HUMAN()?'you':n).join(', '))+(g.agents.includes(HUMAN())?'<div style="margin-top:6px"><button class="btn sm primary" data-ack="'+g.id+'">Acknowledge</button></div>':'')+'</td><td>'+esc(g.body.length>=160?g.body+'…':g.body)+'</td><td>'+esc(ago(g.age))+'</td></tr>').join('')+'</tbody></table>'
:empty('check','All acknowledged','Every message that asked for an acknowledgement has one.'));
}
function renderClaims(){
const c=data.claims;el('claimCount').textContent=c.length?c.length+' active':'';
put('claims',c.length?'<table><thead><tr><th>Agent</th><th>Kind</th><th>Scope</th><th>Until</th></tr></thead><tbody>'+c.map(x=>'<tr><td><span class="cell-who">'+avatar(x.agent,'xs')+esc(x.agent)+'</span></td><td><span class="tag">'+esc(x.kind)+'</span></td><td class="mono">'+esc(x.scope)+(x.note?'<div class="note">'+esc(x.note)+'</div>':'')+'</td><td>'+esc(x.expires_at?central(x.expires_at):'standing')+'</td></tr>').join('')+'</tbody></table>'
:empty('flag','No claims','Nobody holds a claim right now.'));
}
async function renderThreads(){
const list=el('archive').checked?await get('api/threads?archive=1&limit=120'):data.threads;
put('threads',list.length?list.map(t=>'<li data-thread="'+t.id+'">'+ic('thread')+'<div class="t"><strong>'+esc(t.subject||t.topic)+'</strong>'+(t.subject&&t.subject!==t.topic?' <span class="note mono">'+esc(t.topic)+'</span>':'')+'</div>'+(t.state==='closed'?'<span class="tag">closed</span>':'')+(t.unread?'<span class="count-badge accent" title="new">'+t.unread+'</span>':'')+'<span class="s">'+t.messages+' msg</span></li>').join(''):'<li>'+empty('thread','No threads yet','')+'</li>');
}
const msgHtml=m=>'<article class="msg'+(m.importance==='urgent'?' urgent':'')+'">'+avatar(m.from,'sm')+'<div class="msg-main"><div class="h"><strong>'+esc(m.from===HUMAN()?'You':m.from)+'</strong>→ '+esc(m.to===HUMAN()?'you':m.to)+' · <span title="'+esc(m.time)+'">'+esc(central(m.time.replace(' ','T')))+'</span> · <span class="mono">#'+m.id+'</span>'+(m.topic?' <button class="tag blue" data-thread="'+esc(m.topic)+'" title="Open the thread">'+esc(m.topic.startsWith('legacy:')?'archive '+m.topic.slice(7):m.topic)+'</button>':'')+(m.kind!=='note'?' <span class="tag violet">'+esc(m.kind)+'</span>':'')+(m.importance!=='normal'?' <span class="tag '+(m.importance==='urgent'?'red':'amber')+'">'+esc(m.importance)+'</span>':'')+(m.ack?' <span class="tag">ack</span>':'')+'</div><div class="b">'+esc(m.text)+'</div>'+((Array.isArray(m.attachments)?m.attachments:[]).length?'<div class="row" style="margin-top:6px">'+m.attachments.map(f=>'<a class="tag blue" href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'')+'</div></article>';
async function showThread(id){
const t=await get('api/thread?id='+encodeURIComponent(id));openThread=t.thread;openThread.last=t.messages.length?t.messages[t.messages.length-1].id:null;
el('threadTitle').textContent=(t.thread.subject||t.thread.topic)+' · '+t.messages.length+' messages';
el('threadMsgs').innerHTML=(t.summary?'<div class="summary"><b>Summary by '+esc(t.summary.author)+'</b> · '+esc(central(t.summary.at))+'\n'+esc(t.summary.body)+'</div>':'')+(t.messages.map(msgHtml).join('')||empty('thread','Empty',''));
el('replyNote').textContent='';el('threadToChat').hidden=t.thread.topic.startsWith('legacy:');el('replyForm').hidden=t.thread.topic.startsWith('legacy:');el('settleThread').hidden=t.thread.topic.startsWith('legacy:');el('settleThread').textContent=t.thread.state==='closed'?'Reopen thread':'Close thread';
if(!el('threadDialog').open)el('threadDialog').showModal();
el('threadMsgs').scrollTop=el('threadMsgs').scrollHeight;
}
async function runDoctor(){doctorRan=true;el('runDoctor').disabled=true;el('healthSum').textContent='';
put('health',empty('refresh','Checking…','Looking at every agent app, the database, backups and the server.'));
try{const c=await get('api/doctor');const tone={ok:'green',warn:'amber',fail:'red',info:''};const n=s=>c.filter(x=>x.status===s).length;
el('healthSum').textContent=n('ok')+' ok · '+n('warn')+' warnings · '+n('fail')+' failing';
put('health','<table><thead><tr><th>Status</th><th>Area</th><th>Check</th></tr></thead><tbody>'+c.map(x=>'<tr><td><span class="tag '+(tone[x.status]||'')+'"><span class="dot"></span>'+esc(x.status)+'</span></td><td class="mono">'+esc(x.area)+'</td><td>'+esc(x.check)+'<div class="note">'+esc(x.detail)+'</div>'+(x.fix&&x.status!=='ok'?'<div class="note">→ '+esc(x.fix)+'</div>':'')+'</td></tr>').join('')+'</tbody></table>')}
catch(err){put('health','<div class="empty err">'+esc(err.message)+'</div>')}
finally{el('runDoctor').disabled=false}}
async function refresh(){
try{data=await get('api/overview');el('toasts').checked=!!data.toasts;
{const s=el('bulkTemplate');const names=data.templates||[];if(s.options.length!==names.length+1)s.replaceChildren(new Option('No template',''),...names.map(n=>new Option('Template: '+n,n)))}
renderControl();renderStats();renderAttention();renderProjects();renderAvail();renderPins();renderScore();renderQuestions();if(!el('tasksAll').checked)renderTasks();renderQueue();renderAcks();renderClaims();if(!el('archive').checked)renderThreads();
el('conn').className='live ok';el('connText').textContent='Live · '+central(data.now)}
catch(e){el('conn').className='live bad';el('connText').textContent='Reconnecting'}
}
async function flag(target,value){
if(value==='off'&&!confirm('Resume '+(target==='all'?'everyone':target)+'? Workers that check Harbor will start working again.'))return;
try{await post('api/flag',{target,value,reason:el('reason').value.trim()||null});el('reason').value='';await refresh();if(current==='launch')renderLaunch()}catch(e){alert('Not changed: '+e.message)}
}
el('stopAll').onclick=()=>flag('all','on');el('resumeAll').onclick=()=>flag('all','off');
document.addEventListener('click',async e=>{
const go=e.target.closest('[data-go]');if(go)return show(go.dataset.go);
if(e.target.closest('#view-chat,[data-filter]'))return;
const s=e.target.closest('[data-stop]'),r=e.target.closest('[data-resume]'),a=e.target.closest('[data-ack]'),t=e.target.closest('[data-thread]');
const pg=e.target.closest('[data-ping]'),pr=e.target.closest('[data-prompt]'),tk=e.target.closest('[data-task]'),an=e.target.closest('[data-answer]');
const rcb=e.target.closest('[data-rollcall]'),trb=e.target.closest('[data-trial]');
if(rcb){rcb.disabled=true;try{await post('api/rollcall',{agents:[rcb.dataset.rollcall]});rcb.textContent='Asked';renderLaunch()}catch(err){alert(err.message);rcb.disabled=false}return}
if(trb){const who=trb.dataset.trial;const len=(document.querySelector('[data-trialsel="'+who+'"]')||{}).value||'30m';
if(!confirm('Let '+who+' work for '+len.replace('m',' minutes').replace('1h','1 hour')+'? The stop comes back by itself afterwards.'))return;
try{await post('api/flag',{target:who,value:'off',until:len,reason:'trial run'});await refresh();renderLaunch()}catch(err){alert(err.message)}return}
if(an){let value=an.dataset.value;if(!value){value=prompt('Your answer');if(!value)return;value=(an.parentElement.querySelectorAll('[data-answer]').length>1?'other: ':'')+value}
an.disabled=true;try{await post('api/answer',{question:Number(an.dataset.answer),answer:value});await refresh()}catch(err){an.disabled=false;alert(err.message)}return}
if(pg){pg.disabled=true;try{await post('api/ping',{agent:pg.dataset.ping});pg.textContent='Pinged'}catch(err){alert(err.message);pg.disabled=false}return}
if(pr){try{const j=await get('api/prompt?agent='+encodeURIComponent(pr.dataset.prompt));el('promptTitle').textContent='Restart prompt for '+pr.dataset.prompt;el('promptText').value=j.prompt;el('promptNote').textContent='Paste this into a fresh session for the agent.';el('promptDialog').showModal()}catch(err){alert(err.message)}return}
if(tk){const to=tk.dataset.to;if(to==='cancelled'&&!confirm('Cancel task #'+tk.dataset.task+'?'))return;try{await post('api/task',{action:'update',id:Number(tk.dataset.task),state:to});await refresh();renderTasks()}catch(err){alert(err.message)}return}
if(s)return flag(s.dataset.stop,'on');if(r)return flag(r.dataset.resume,'off');
if(a){try{await post('api/ack',{ids:[Number(a.dataset.ack)]});refresh()}catch(err){alert(err.message)}return}
if(t&&t.dataset.thread)showThread(t.dataset.thread).catch(err=>alert(err.message));
});
el('closeThread').onclick=()=>el('threadDialog').close();
el('threadToChat').onclick=()=>{if(!openThread)return;const t=openThread.topic;el('threadDialog').close();Chat.openThread(t)};
el('exportThread').onclick=()=>{if(openThread)location.href='api/export?thread='+encodeURIComponent(openThread.topic)};
el('settleThread').onclick=async()=>{if(!openThread)return;try{await post('api/thread-state',{thread:openThread.topic,state:openThread.state==='closed'?'open':'closed'});await showThread(openThread.id);refresh()}catch(err){alert(err.message)}};
el('since').onchange=renderDigest;
el('tasksAll').onchange=renderTasks;
el('toasts').onchange=async e=>{try{await post('api/toasts',{on:e.target.checked})}catch(err){alert(err.message);e.target.checked=!e.target.checked}};
el('bulkForm').onsubmit=async e=>{e.preventDefault();const text=el('bulkText').value.trim();if(!text)return;
try{const r=await post('api/bulk',{text,template:el('bulkTemplate').value||null,priority:el('bulkPriority').value});
el('bulkNote').textContent='Added '+r.created+' task(s)'+(r.skipped.length?'; skipped: '+r.skipped.join('; '):'')+'.';el('bulkText').value='';await refresh();renderTasks()}
catch(err){el('bulkNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
el('taskForm').onsubmit=async e=>{e.preventDefault();const title=el('tTitle').value.trim();if(!title)return;
const after=el('tAfter').value.split(/[\s,#]+/).filter(Boolean).map(Number).filter(n=>n>0);
try{const r=await post('api/task',{action:'add',title,scope:el('tScope').value.trim()||null,assignee:el('tAssignee').value||null,priority:el('tPriority').value,after});
el('tTitle').value='';el('tScope').value='';el('tAfter').value='';el('taskNote').textContent='Added task #'+r.task.id+'.';await refresh();renderTasks()}
catch(err){el('taskNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
el('rollcallAll').onclick=async()=>{try{await post('api/rollcall',{});renderLaunch()}catch(err){alert(err.message)}};
el('runDoctor').onclick=runDoctor;
el('availBtn').onclick=()=>{const s=data&&data.presence||{status:'here'};for(const r of document.querySelectorAll('[name=st]'))r.checked=r.value===s.status;el('availNote').value=s.note||'';el('availDialog').showModal()};
el('availClose').onclick=()=>el('availDialog').close();
el('availForm').onsubmit=async e=>{e.preventDefault();const status=document.querySelector('[name=st]:checked').value;let until=el('availUntil').value||null;
if(until==='tomorrow'){const d=new Date();d.setDate(d.getDate()+1);d.setHours(8,0,0,0);until=d.toISOString().replace(/\.\d+Z$/,'Z')}
try{await post('api/presence',{status,until:status==='here'?null:until,note:el('availNote').value.trim()||null});el('availDialog').close();refresh()}catch(err){alert(err.message)}};
el('promptClose').onclick=()=>el('promptDialog').close();
el('promptCopy').onclick=async()=>{const t=el('promptText');try{await navigator.clipboard.writeText(t.value);el('promptNote').textContent='Copied.'}catch(e){t.select();document.execCommand('copy');el('promptNote').textContent='Copied.'}};
el('projectSelect').onchange=e=>{location.href='/p/'+encodeURIComponent(e.target.value)+'/board'+location.hash};
el('archive').onchange=()=>renderThreads();
el('replyForm').onsubmit=async e=>{e.preventDefault();const text=el('replyText').value.trim();if(!text||!openThread)return;
try{const j=await post('send',{text,thread:openThread.topic,reply_to:openThread.last,ack:el('replyAck').checked,importance:el('replyImportance').value});el('replyText').value='';el('replyNote').textContent=j.warnings&&j.warnings.length?'Sent · '+j.warnings.join('; '):'Sent';await showThread(openThread.id)}catch(err){el('replyNote').innerHTML='<span class="err">'+esc(err.message)+'</span>'}};
el('searchForm').onsubmit=async e=>{e.preventDefault();const q=el('q').value.trim();if(!q)return;
try{let ctx='';if(/^(fn:|range:|file:|name:|task:)\S+$|^0x[0-9a-f]+$/i.test(q)){const c=await get('api/context?scope='+encodeURIComponent(q));ctx='<div class="ctx">'+esc(c.text)+'</div>'}
const hits=ctx?[]:await get('api/search?q='+encodeURIComponent(q));el('results').innerHTML=ctx+(hits.length?hits.map(msgHtml).join(''):(ctx?'':empty('search','No message matches','Try fewer words, or a filter like <code>from:Kimi</code>.')))}
catch(err){el('results').innerHTML='<div class="err">'+esc(err.message)+'</div>'}};
el('menuBtn').onclick=()=>el('shell').classList.add('open');
el('scrim').onclick=()=>el('shell').classList.remove('open');
window.addEventListener('hashchange',()=>{const v=location.hash.slice(1);if(v&&v!==current)show(v)});
const TIERS=[['exact','Exact match','byte-identical with the game’s own compiler'],['verified','Verified','behaves the same as the game in the emulator'],
['readable','Readable','readable C that passed review'],['deferred','Deferred','set aside with a reason after failed attempts'],['raw','Raw','not decompiled yet'],
['library','Library','C runtime, D3DX9 and other linked libraries: never decompiled'],['engine','Engine','Gamebryo engine code: names only']];
const fmt=n=>Number(n||0).toLocaleString('en-US');
function renderDecomp(){renderProgress();renderNextUp();renderDeferred()}
async function renderProgress(){let p;try{p=await get('api/decomp/progress')}catch(e){put('progress','<div class="empty err">'+esc(e.message)+'</div>');return}
const t=p.tiers,own=['exact','verified','readable','deferred','raw'].reduce((a,k)=>a+t[k].functions,0),done=t.exact.functions+t.verified.functions+t.readable.functions;
el('progressSum').textContent=fmt(done)+' of '+fmt(own)+' game functions decompiled';
put('progress',tierBar(t)+'<div class="lb-legend">'+TIERS.map(([k,l])=>'<span><i class="tier-key tier-'+k+'"></i>'+esc(l)+' '+fmt(t[k].functions)+'</span>').join('')+'</div><div class="note" style="margin-top:10px">Bytes, categories, the trend and the ship’s log: <a href="#logbook" data-go="logbook">open the Logbook</a>.</div>');}
function tierTable(t){return '<table style="margin-top:14px"><thead><tr><th>Tier</th><th style="text-align:right">Functions</th><th style="text-align:right">Bytes</th><th>Meaning</th></tr></thead><tbody>'
+TIERS.map(([k,l,d])=>'<tr><td><span class="tier-key tier-'+k+'"></span>'+esc(l)+'</td><td class="num">'+fmt(t[k].functions)+'</td><td class="num">'+fmt(t[k].bytes)+'</td><td class="note">'+esc(d)+'</td></tr>').join('')+'</tbody></table>'}
function tierBar(t){return '<div class="tierbar" role="img" aria-label="'+esc(TIERS.map(([k,l])=>l+' '+t[k].functions).join(', '))+'">'+TIERS.map(([k,l])=>t[k].functions?'<span class="tier-'+k+'" style="flex:'+t[k].functions+'" title="'+esc(l)+': '+fmt(t[k].functions)+' functions"></span>':'').join('')+'</div>'}
async function renderNextUp(){if(!el('nextUp')._html)put('nextUp',empty('clock','Ranking the bands…','This takes a few seconds the first time.'));let q;
try{q=await get('api/decomp/queues')}catch(e){put('nextUp','<div class="empty err">'+esc(e.message)+'</div>');return}
put('nextUp',q.length?q.map(a=>'<div class="band"><div class="cell-who">'+avatar(a.agent,'xs')+'<b>'+esc(a.agent)+'</b><span class="note mono">'+esc(a.bands.map(b=>b.join('–')).join(', '))+'</span></div>'
+(a.next.length?'<ul class="attn">'+a.next.map(f=>'<li><a href="#" class="mono" data-fn="'+esc(f.address)+'">'+esc(f.address)+'</a> '+esc(f.name||(f.file||'').replace(/\.c$/,''))+'<div class="note">'+esc(f.why)+'</div></li>').join('')+'</ul>'
:'<div class="note">'+esc(a.note||'Nothing left in this band.')+'</div>')+'</div>').join(''):empty('list','No bands yet','The coordinator gives each agent an address band.'))}
async function renderDeferred(){let d;try{d=await get('api/decomp/deferred')}catch(e){return}el('deferCount').textContent=d.length||'';
put('deferred',d.length?'<table><thead><tr><th>Function</th><th>Reason</th><th>Evidence</th></tr></thead><tbody>'+d.map(x=>'<tr><td><a href="#" class="mono" data-fn="'+esc(x.address)+'">'+esc(x.address)+'</a><div class="note">'+esc(x.name||'')+'</div></td><td><span class="tag amber">'+esc(x.reason)+'</span><div class="note">'+esc(x.author)+'</div></td><td class="note">'+esc(x.evidence)+'</td></tr>').join('')+'</tbody></table>'
:empty('check','Nothing deferred','After two failed attempts an agent sets a function aside here, with the reason and what it found.'))}
async function inspectFn(){const target=el('fnTarget').value.trim();if(!target)return;put('fnInfo',empty('search','Looking it up…',''));let f;
try{f=await get('api/decomp/function?target='+encodeURIComponent(target))}catch(e){put('fnInfo','<div class="empty err">'+esc(e.message)+'</div>');return}
const fn=f.function||{},mac=f.mac_name,src=f.source_file,m=f.match,b=f.behaviour,c=f.convention_from_bytes;
const row=(k,v)=>v?'<dt>'+esc(k)+'</dt><dd>'+v+'</dd>':'';
const note=f.names_row?String(f.names_row).split(',').slice(3).join(',').replace(/^"|"$/g,'').replace(/""/g,'"'):'';
const exact=m&&['exact','exact-relaxed','match'].includes(m.status),near=m&&m.status==='exact-but-targets-differ';
put('fnInfo','<div class="fn-head"><h3 class="mono">'+esc(fn.start||f.address)+'</h3><b>'+esc(fn.name||'unnamed')+'</b>'
+'<span class="tag '+(f.status==='readable'?'violet':'')+'">'+esc(f.status)+'</span>'+(f.library?'<span class="tag">library</span>':'')
+(m?'<span class="tag '+(exact?'green':near?'amber':'')+'">'+(exact?'exact match':near?'same bytes, different targets':esc(m.status)+(m.similarity!=null?' · '+Math.round(m.similarity*100)+'% similar':''))+'</span>':'')
+(b?'<span class="tag '+(b.verdict==='same'?'green':b.verdict==='differs'?'red':'')+'">emulator: '+esc(b.verdict)+'</span>':'')
+'<span class="note">'+esc(fn.size??'?')+' bytes · '+esc(f.category||'')+'</span></div>'
+'<dl class="fn-facts">'+row('Original name',mac?'<code>'+esc(mac.demangled)+'</code> <span class="note">'+esc(mac.confidence)+' confidence, from the Mac build</span>':'')
+row('Source file',src?'<code>'+esc(src.source_file)+'</code>'+(src.lines?' line '+esc(src.lines):'')+' <span class="note">'+esc(src.confidence)+' confidence, from the Xbox build</span>':'')
+row('Convention',c?esc(c.summary)+(c.ret_pops&&c.ret_pops.some(x=>x)?' · cleans up '+esc(c.ret_pops.join('/'))+' bytes':''):'')
+row('Decompiled file',f.src_file?'<span class="mono">'+esc(f.src_file)+'</span>':'')
+row('Deferred',f.deferred?'<span class="tag amber">'+esc(f.deferred.reason)+'</span> '+esc(f.deferred.evidence):'')
+row('Emulator',b&&b.verdict!=='same'?esc(b.reason||''):b&&b.weak?'same, but no trial ran to the end in both':'')
+row('Prototype',f.prototype?'<code>'+esc(f.prototype)+'</code>':'')+row('Team note',note?esc(note.slice(0,700)):'')+'</dl>'
+(f.similar&&f.similar.length?'<h3 class="sub-h">Near-duplicates</h3><ul class="attn">'+f.similar.map(s=>'<li><a href="#" class="mono" data-fn="'+esc(s.address)+'">'+esc(s.address)+'</a> '+esc(s.name||'')+' <span class="note">'+Math.round(s.score*100)+'% alike · '+esc(s.status)+'</span></li>').join('')+'</ul>':'')
+'<details data-k="disasm"><summary>Disassembly</summary><pre class="disasm">'+esc(f.disassembly||'')+'</pre></details>')}
async function renderTools(){let t;try{t=await get('api/decomp/tools')}catch(e){put('retools','<div class="empty err">'+esc(e.message)+'</div>');return}
const bad=t.filter(x=>!x.ok).length;el('reSum').textContent=bad?bad+' to check':'all in place';
put('retools','<table><thead><tr><th>Status</th><th>Tool</th></tr></thead><tbody>'+t.map(x=>'<tr><td><span class="tag '+(x.ok?'green':'amber')+'"><span class="dot"></span>'+(x.ok?'ok':'check')+'</span></td><td>'+esc(x.tool)+'<div class="note">'+esc(x.detail)+'</div></td></tr>').join('')+'</tbody></table>')}
const LB_CATS=[['game','Game code','Firaxis game logic: decompiled to readable C, then ported to Rust'],['engine','Engine','Gamebryo: named and documented, replaced in the port'],
['platform','Platform','Miles, Bink, D3D9 and input wrappers: replaced in the port'],['crt','C runtime','MSVC 7.1 runtime: replaced by Rust’s standard library'],['unsorted','Unsorted','not classified yet; each lands in one of the four above']];
let LB=null;
const pct=n=>Number(n||0).toFixed(Number(n)>=10?1:2)+'%';
const kb=b=>b>=1048576?(b/1048576).toFixed(2)+' MB':Math.round((b||0)/1024)+' KB';
const cssVar=n=>getComputedStyle(el('view-logbook')).getPropertyValue(n).trim();
async function renderLogbook(){try{LB=await get('api/logbook')}catch(e){put('lbStamp','<span class="err">'+esc(e.message)+'</span>');return}
const s=LB.stats||{},cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0};
const cur=LB.milestones.find(m=>/progress/i.test(m.state||''));el('lbMilestone').textContent=cur?'Milestone '+cur.code+' · '+cur.title:'Milestones';
const au=LB.auto||{};put('lbStamp',(s.updated_at?'Statistics updated <b>'+esc(central(s.updated_at))+'</b><br>main at <b>'+esc(s.commit||'–')+'</b>':'No statistics yet')+(au.last_refresh?'<br>Keeps itself current: last checked '+esc(central(au.last_refresh))+(au.last_error?' · <span class="err">'+esc(String(au.last_error).slice(0,120))+'</span>':''):''));
const total=s.functions||0,game=cat('game').functions,done=(s.by_status&&s.by_status.decompiled||{}).functions||0,uns=cat('unsorted').functions;
const handled=total-uns-Math.max(0,game-done);
put('lbFigs',[[pct(total?100*handled/total:0)+' done',fmt(handled)+' of '+fmt(total)+' functions handled','game code decompiled, everything else identified'],
[fmt(done)+' of '+fmt(game),'game functions decompiled to readable C',fmt(game-done)+' to go'],
[fmt(uns),'functions not sorted yet','each one is either game code (to decompile) or engine/library code (to name)']]
.map(([v,l,n])=>'<div class="lb-fig"><div class="v">'+esc(v)+'</div><div class="l">'+esc(l)+'</div><div class="n">'+esc(n)+'</div></div>').join(''));
put('lbLegend',LB_CATS.map(([k,n])=>'<span><i class="lb-sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></i>'+esc(n)+'</span>').join('')+'<span><i class="lb-sw" style="background:var(--lb-done)"></i>decompiled share under each column</span>');
drawLbMap();pmLoad().then(()=>{if(PM.doc)pmLayout()});renderLbWhere(s);renderLbTrend(LB.history);renderLbMilestones(LB.milestones);
el('lbLogCount').textContent=LB.log_total?LB.log_total+' entries':'';
put('lbLog',LB.log.length?LB.log.map(e=>'<li><time>'+esc(central(e.at))+'</time><div><div class="t"><span class="kind">'+esc(e.kind)+'</span>'+esc(e.title)+'</div><div class="d">'+esc(e.detail)+'</div></div></li>').join(''):'<li class="note">No entries yet.</li>')}
async function renderLbWhere(s){const total=s.bytes||0,cat=k=>(s.by_category||{})[k]||{functions:0,bytes:0},uns=cat('unsorted'),sorted=total?100*(total-uns.bytes)/total:0;
let tiers='';try{const p=await get('api/decomp/progress');tiers='<h3 class="sub-h" style="margin-top:18px">Game functions by how far they have come</h3>'+tierBar(p.tiers)+tierTable(p.tiers)}catch(e){}
put('lbWhere','<div><span class="lb-big">'+pct(sorted)+'</span> <span class="note">of the executable’s code is sorted into a category · '+fmt(uns.functions)+' functions ('+kb(uns.bytes)+') still unsorted</span></div>'
+'<div class="lb-stack" role="img" aria-label="Code bytes by category">'+LB_CATS.map(([k,n])=>{const sh=total?100*cat(k).bytes/total:0;return '<i class="'+(k==='unsorted'?'lb-hatch':'')+'" style="flex:0 0 '+sh.toFixed(3)+'%;background:var(--lb-'+k+')" title="'+esc(n)+': '+sh.toFixed(1)+'% of bytes"></i>'}).join('')+'</div>'
+'<div class="lb-axis"><span>0</span><span>sorted '+pct(sorted)+' · unsorted '+pct(100-sorted)+'</span><span>'+kb(total)+'</span></div>'
+LB_CATS.map(([k,n,plan])=>{const c=cat(k),sh=total?100*c.bytes/total:0;return '<div class="lb-cat"><span class="sw'+(k==='unsorted'?' lb-hatch':'')+'" style="background:var(--lb-'+k+')"></span><div><b>'+esc(n)+'</b><div class="plan">'+esc(plan)+'</div></div><div class="nums"><b>'+sh.toFixed(1)+'%</b> of bytes<br>'+fmt(c.functions)+' fn · '+kb(c.bytes)+'</div>'
+(k==='game'?'<div class="sub"><div class="tierbar" style="height:8px"><span class="tier-readable" style="flex:'+(s.game_bytes_decompiled_percent||0)+'"></span><span style="flex:'+Math.max(0,100-(s.game_bytes_decompiled_percent||0))+';background:transparent"></span></div><div class="note">'+pct(s.game_bytes_decompiled_percent)+' of game bytes decompiled</div></div>':'')+'</div>'}).join('')
+'<div class="note" style="margin-top:10px">Bar and shares are by bytes of code; counts are functions. Only game code is decompiled and ported; the rest is named and replaced.</div>'+tiers)}
function drawLbMap(){const m=LB&&LB.map,c=el('lbMap');if(!c||!m||!c.clientWidth)return;const r=window.devicePixelRatio||1,w=c.clientWidth,h=c.clientHeight;
c.width=Math.round(w*r);c.height=Math.round(h*r);const g=c.getContext('2d');g.setTransform(r,0,0,r,0,0);g.clearRect(0,0,w,h);
const cells=m.cells.map(x=>x.split(',').map(Number)),max=Math.max(1,...cells.map(x=>x.slice(0,5).reduce((a,b)=>a+b,0)));
const colors=LB_CATS.map(([k])=>cssVar('--lb-'+k)),done=cssVar('--lb-done'),rule=cssVar('--line'),cw=w/cells.length,base=h-12;
g.fillStyle=rule;g.fillRect(0,base,w,1);
cells.forEach((x,i)=>{let y=base;for(let k=0;k<5;k++){const hh=x[k]/max*(base-4);if(hh<=0)continue;g.fillStyle=colors[k];g.fillRect(i*cw+.5,y-hh,Math.max(1,cw-1),hh);y-=hh}
const tot=x.slice(0,5).reduce((a,b)=>a+b,0);if(tot>0&&x[5]>0){g.fillStyle=done;g.fillRect(i*cw+.5,base+4,Math.max(1,(cw-1)*Math.min(1,x[5]/tot)),6)}});
el('lbLow').textContent=m.low;el('lbHigh').textContent=m.high}
function renderLbTrend(points){const svg=el('lbTrend');if(!points.length){svg.innerHTML='';el('lbTrendNote').textContent='The trend starts with the second report.';return}
const n=points.length,x=i=>n===1?150:i*300/(n-1),y=v=>104-Math.min(100,v||0)/100*96,path=k=>points.map((p,i)=>(i?'L':'M')+x(i).toFixed(1)+','+y(p[k]).toFixed(1)).join(' ');
svg.innerHTML=[0,25,50,75,100].map(v=>'<line x1="0" x2="300" y1="'+y(v)+'" y2="'+y(v)+'" stroke="var(--line)" stroke-width="0.5"/>').join('')
+[['classified','--lb-game'],['decompiled','--lb-platform'],['done','--lb-done']].map(([k,c])=>{const pts=points.map((p,i)=>[i,p[k]]).filter(q=>q[1]!=null);return pts.length?'<path d="'+pts.map(([i,v],j)=>(j?'L':'M')+x(i).toFixed(1)+','+y(v).toFixed(1)).join(' ')+'" fill="none" stroke="var('+c+')" stroke-width="2.5" vector-effect="non-scaling-stroke"/>':''}).join('');
el('lbTrendNote').textContent=n+' reports since '+new Date(points[0].at).toLocaleDateString()+'. Scale 0–100%.'}
function renderLbMilestones(list){let last='';put('lbMilestones',list.length?list.map(m=>{const head=m.phase&&m.phase!==last?'<div class="lb-phase">'+esc(m.phase)+'</div>':'';last=m.phase||last;
const items=m.items||[],done=items.filter(i=>i.done).length,li=items.map(i=>'<li class="'+(i.done?'done':'')+'">'+esc(i.text)+'</li>').join(''),open=/progress/i.test(m.state||'');
return head+'<div class="lb-ms"><h3><span class="code">'+esc(m.code)+'</span>'+esc(m.title)+'<span class="note">'+done+'/'+items.length+(m.state?' · '+esc(m.state):'')+'</span></h3>'+(open?'<ul>'+li+'</ul>':'<details data-k="ms-'+esc(m.id)+'"><summary class="note">Show '+items.length+' steps</summary><ul>'+li+'</ul></details>')+'</div>'}).join(''):empty('flag','No milestones yet',''))}
window.addEventListener('resize',()=>{if(current==='logbook')drawLbMap()});
/* progress map (decomp.dev style): one compact document per change, laid out once per load, filter or resize, drawn on a canvas */
const PM={doc:null,version:null,unit:null,hist:null,rects:[],items:[],hover:-1,base:null,colors:null,busy:false,topFilter:''};
const PM_LABEL={exact:'exact match',verified:'verified in the emulator',readable:'readable',deferred:'deferred',raw:'raw',unsorted:'unsorted (not counted)',engine:'engine: named, not decompiled',library:'library: named, not decompiled'};
const PM_KIND={game:'game code',unsorted:'unsorted',engine:'engine',library:'library'};
const kB=b=>b>=1e6?(b/1e6).toFixed(2)+' MB':(b/1e3).toFixed(2)+' kB';
const hx=a=>'0x'+Number(a).toString(16).toUpperCase();
const pc=(b,t)=>(t?100*b/t:0).toFixed(2);
function rel(iso){const t=Date.parse(iso||'');if(!t)return 'at an unknown time';const s=(Date.now()-t)/1000;if(s<90)return 'just now';const m=s/60;if(m<90)return Math.round(m)+' minutes ago';const h=m/60;if(h<36)return Math.round(h)+' hours ago';return Math.round(h/24)+' days ago'}
async function pmLoad(){if(PM.busy)return;PM.busy=true;try{
const d=await get('api/logbook/progress-map'+(PM.version?'?have='+encodeURIComponent(PM.version):''));if(d.same)return;
const U=d.unit_fields;d.units=d.units.map((u,i)=>{const o={i,fns:[]};U.forEach((k,j)=>o[k]=u[j]);o.pct=o.counted?100*(o.exact+o.verified+o.readable)/o.counted:null;o.lname=o.name.toLowerCase();return o});
d.functions.forEach(f=>{const o={unit:f[0],address:f[1],size:f[2],status:d.statuses[f[3]],name:f[4]};o.lname=o.name.toLowerCase();d.units[o.unit].fns.push(o)});delete d.functions;
PM.doc=d;PM.version=d.version;if(PM.unit!=null&&!d.units[PM.unit])PM.unit=null;
PM.latest=(()=>{const h=d.history;for(let i=h.length-1;i>=0;i--)if(h[i][0]===d.updated_at)return i;return h.length})();
pmUnitsSelect();pmHeader();pmLayout()}
catch(e){put('pmBars','<div class="err">'+esc(e.message)+'</div>')}finally{PM.busy=false}}
function pmBar(label,size,segs,total,extra){const sum=segs.reduce((a,s)=>a+s[1],0);
return '<div class="pm-bar-row"><div class="pm-bar-l"><b>'+esc(label)+'</b><span class="note">('+esc(size)+')</span>'+(extra?'<span class="note">'+esc(extra)+'</span>':'')+'<span class="grow"></span><span class="mono">'+pc(sum,total)+'%</span></div>'
+'<div class="pm-bar" role="img" aria-label="'+esc(label+': '+segs.filter(s=>s[1]>0).map(s=>s[2]).join('; '))+'">'+segs.map(([c,b,t])=>b>0?'<span class="pm-seg '+c+'" style="width:'+(total?100*b/total:0).toFixed(3)+'%" data-tip="'+esc(t)+'"></span>':'').join('')+'</div></div>'}
function pmCodeSegs(x,total){return [['pm-exact',x.exact,pc(x.exact,total)+'% perfect match ('+kB(x.exact)+')'],
['pm-verified',x.verified,pc(x.verified,total)+'% decompiled, verified in the emulator, not exact ('+kB(x.verified)+')'],
['pm-readable',x.readable,pc(x.readable,total)+'% decompiled, readable only ('+kB(x.readable)+')']]}
function pmHeader(){const d=PM.doc;if(!d)return;const h=d.history,pt=PM.hist!=null?h[PM.hist]:null,c=d.code;
el('pmProject').textContent=d.project;
if(!pt){el('pmPct').textContent=d.percent.toFixed(2)+'%';
put('pmBars',pmBar('Code',kB(c.bytes),pmCodeSegs(c,c.bytes),c.bytes,'game code · '+fmt(c.decompiled_functions)+' of '+fmt(c.functions)+' functions')
+(d.data?pmBar('Data',fmt(d.data.addresses)+' addresses',[['pm-named',d.data.named,pc(d.data.named,d.data.addresses)+'% of the data addresses the code touches have a name in globals.csv ('+fmt(d.data.named)+' of '+fmt(d.data.addresses)+')']],d.data.addresses,'globals named'):''));
const at=d.commit.at||d.updated_at,moved=d.main.sha&&d.commit.sha&&d.main.sha.slice(0,7)!==String(d.commit.sha).slice(0,7);
put('pmCommit','Updated <b title="'+esc(at||'')+'">'+esc(rel(at))+'</b> in commit <b class="mono">'+esc(d.commit.sha||'–')+'</b>'+(moved?' <span class="note">· main has moved on to '+esc(d.main.sha)+'; the statistics follow within minutes</span>':''));
el('pmHistNote').textContent=d.percent!==d.logbook_percent&&d.logbook_percent!=null?'The Logbook statistics say '+d.logbook_percent+'%; they are being rebuilt.':''}
else{const p=pt[1],gb=pt[3];el('pmPct').textContent=Number(p).toFixed(2)+'%';
put('pmBars',pmBar('Code',gb?kB(gb):'size not recorded',[['pm-readable',p,Number(p).toFixed(2)+'% decompiled'+(gb?' ('+kB(gb*p/100)+')':'')+'; the split into exact, verified and readable was not recorded']],100,'game code'));
put('pmCommit','Recorded <b title="'+esc(pt[0])+'">'+esc(rel(pt[0]))+'</b>'+(pt[2]?' in commit <b class="mono">'+esc(pt[2])+'</b>':' <span class="note">· commit not recorded</span>')+' <span class="note">· report '+(PM.hist+1)+' of '+h.length+'</span>');
el('pmHistNote').textContent='An earlier report: the headline and the Code bar are from then. The map and the Data bar show Latest, since per-function history is not stored.'}
el('pmPrev').disabled=!h.length||PM.hist===0||(PM.hist==null&&PM.latest<1);el('pmNext').disabled=pt==null;el('pmLatest').disabled=pt==null}
function pmStep(k){const d=PM.doc;if(!d)return;const L=PM.latest;let i=PM.hist==null?L:PM.hist;
i=k==='latest'?L:k==='prev'?Math.max(0,i-1):i+1;PM.hist=i>=L?null:i;pmHeader()}
function pmFilter(){const conds=[],words=[],cmp=(op,a,b)=>op==='<'?a<b:op==='<='?a<=b:op==='>'?a>b:a>=b;
for(const t of (el('pmFilter').value||'').trim().toLowerCase().split(/\s+/).filter(Boolean)){let m;
if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)%$/)))conds.push(['pct',m[1],+m[2]]);
else if((m=t.match(/^([<>]=?)(\d+(?:\.\d+)?)(b|k|kb|m|mb)?$/)))conds.push(['size',m[1],+m[2]*({b:1,k:1e3,kb:1e3,m:1e6,mb:1e6}[m[3]||'b'])]);
else words.push(t)}
return (name,kind,pct,size,more)=>conds.every(([k,op,v])=>k==='pct'?pct!=null&&cmp(op,pct,v):cmp(op,size,v))&&words.every(w=>name.includes(w)||kind===w||(more?more(w):false))}
function pmLayout(){const d=PM.doc,c=el('pmMap');if(!d||!c||!c.clientWidth)return;const test=pmFilter();let items,of;
if(PM.unit==null){items=d.units.filter(u=>u.bytes>0&&test(u.lname,u.kind,u.pct,u.bytes,w=>u.fns.some(f=>f.lname.includes(w))));of=d.units.length+' units'}
else{const u=d.units[PM.unit];items=u.fns.filter(f=>f.size>0&&test(f.lname,f.status,['exact','verified','readable'].includes(f.status)?100:['engine','library'].includes(f.status)?null:0,f.size));of=u.fns.length+' functions'}
const sz=x=>x.fns?x.bytes:x.size;items.sort((a,b)=>sz(b)-sz(a));
PM.items=items;PM.w=c.clientWidth;PM.h=c.clientHeight;PM.rects=squarify(items.map(sz),0,0,PM.w,PM.h);PM.hover=-1;
el('pmCount').textContent=items.length+' of '+of+' · '+kB(items.reduce((a,x)=>a+sz(x),0));pmCrumb();pmPaint()}
function squarify(vals,x,y,w,h){const out=new Array(vals.length),total=vals.reduce((a,b)=>a+b,0);if(!total||w<=0||h<=0)return out;
const scale=w*h/total,worst=(s,mn,mx,side)=>Math.max(side*side*mx/(s*s),s*s/(side*side*mn));let i=0;
while(i<vals.length){const side=Math.min(w,h);let a=vals[i]*scale,sum=a,mn=a,mx=a,best=worst(sum,mn,mx,side),j=i+1;
while(j<vals.length){const b=vals[j]*scale,s2=sum+b,m2=Math.min(mn,b),M2=Math.max(mx,b),w2=worst(s2,m2,M2,side);if(w2>best)break;sum=s2;mn=m2;mx=M2;best=w2;j++}
if(w>=h){const cw=sum/h;let yy=y;for(let k=i;k<j;k++){const hh=vals[k]*scale/cw;out[k]=[x,yy,cw,hh];yy+=hh}x+=cw;w-=cw}
else{const rh=sum/w;let xx=x;for(let k=i;k<j;k++){const ww=vals[k]*scale/rh;out[k]=[xx,y,ww,rh];xx+=ww}y+=rh;h-=rh}
i=j}
return out}
function pmColors(){if(PM.colors)return PM.colors;const host=el('view-logbook'),probe=document.createElement('i'),cv=document.createElement('canvas');host.appendChild(probe);cv.width=cv.height=1;
const g=cv.getContext('2d',{willReadFrequently:true}),rgb=v=>{probe.style.color='var('+v+')';const s=getComputedStyle(probe).color;g.clearRect(0,0,1,1);g.fillStyle='#808080';g.fillStyle=s;g.fillRect(0,0,1,1);const p=g.getImageData(0,0,1,1).data;return [p[0],p[1],p[2]]};
const C={};['exact','verified','readable','deferred','raw','unsorted','engine','library'].forEach(k=>C[k]=rgb('--pm-'+k));C.bg=rgb('--surface');C.text=rgb('--text');probe.remove();
return PM.colors=C}
const pmCss=a=>'rgb('+a.map(v=>Math.round(v)).join(',')+')';
function pmFill(it,C){if(!it.fns)return C[it.status];if(it.kind!=='game'||it.pct==null)return C[it.kind==='unsorted'?'unsorted':it.kind==='engine'?'engine':'library'];
const t=it.pct<=0?0:.18+.82*it.pct/100;return C.raw.map((v,k)=>v+(C.exact[k]-v)*t)}
function pmPaint(){const c=el('pmMap');if(!c||!c.clientWidth)return;const r=window.devicePixelRatio||1,W=c.clientWidth,H=c.clientHeight,C=pmColors();
c.width=Math.round(W*r);c.height=Math.round(H*r);const off=PM.base||(PM.base=document.createElement('canvas'));off.width=c.width;off.height=c.height;
const g=off.getContext('2d');g.setTransform(r,0,0,r,0,0);g.fillStyle=pmCss(C.bg);g.fillRect(0,0,W,H);g.textBaseline='top';
const font=getComputedStyle(c).fontFamily;
PM.items.forEach((it,i)=>{const q=PM.rects[i];if(!q||q[2]<.5||q[3]<.5)return;const [x,y,w,h]=q,f=pmFill(it,C);g.fillStyle=pmCss(f);g.fillRect(x+.5,y+.5,Math.max(.5,w-1),Math.max(.5,h-1));
if(w<44||h<17)return;const light=(.299*f[0]+.587*f[1]+.114*f[2])>150;g.fillStyle=light?'rgba(0,0,0,.82)':'rgba(255,255,255,.92)';
g.save();g.beginPath();g.rect(x+1,y+1,w-2,h-2);g.clip();g.font='600 11.5px '+font;g.fillText(it.fns?it.name.split('/').pop():it.name,x+5,y+4);
if(h>32){g.font='11px '+font;g.globalAlpha=.8;g.fillText(it.fns?(it.pct!=null&&it.kind==='game'?it.pct.toFixed(1)+'%':PM_KIND[it.kind])+' · '+kB(it.bytes):PM_LABEL[it.status].split(':')[0]+' · '+it.size+' B',x+5,y+18)}g.restore()});
pmBlit()}
function pmBlit(){const c=el('pmMap');if(!PM.base||!c.width)return;const g=c.getContext('2d'),r=window.devicePixelRatio||1;g.setTransform(1,0,0,1,0,0);g.drawImage(PM.base,0,0);
const q=PM.rects[PM.hover];if(q){g.setTransform(r,0,0,r,0,0);g.strokeStyle=pmCss(pmColors().text);g.lineWidth=2;g.strokeRect(q[0]+1,q[1]+1,Math.max(1,q[2]-2),Math.max(1,q[3]-2))}}
function pmTipHtml(it){if(it.fns){const g=it.kind==='game'&&it.pct!=null;
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.lo)+'–'+hx(it.hi)+' · '+kB(it.bytes)+' · '+fmt(it.functions)+' functions</div>'
+(g?'<div><b>'+it.pct.toFixed(2)+'%</b> of '+kB(it.counted)+' game code decompiled</div><div class="mono">exact '+kB(it.exact)+' · verified '+kB(it.verified)+' · readable '+kB(it.readable)+'</div>'
:'<div>'+esc(PM_KIND[it.kind])+(it.kind==='unsorted'?': not counted until sorted':': named, not decompiled; not counted')+'</div>')+'<div class="mono">click to see its functions</div>'}
return '<b>'+esc(it.name)+'</b><div class="mono">'+hx(it.address)+'–'+hx(it.address+it.size)+' · '+fmt(it.size)+' bytes</div><div>'+esc(PM_LABEL[it.status])+'</div><div class="mono">click to inspect in the Decomp view</div>'}
function pmTip(html,x,y){const t=el('pmTip');if(html==null){t.hidden=true;t._for=null;return}if(t._for!==html){t.innerHTML=html;t._for=html}t.hidden=false;
const w=t.offsetWidth,h=t.offsetHeight;t.style.left=Math.max(8,Math.min(x+14,innerWidth-w-8))+'px';t.style.top=(y+16+h>innerHeight?y-h-10:y+16)+'px'}
function pmCrumb(){const d=PM.doc;if(PM.unit==null){put('pmCrumb','');return}const u=d.units[PM.unit];
put('pmCrumb','<button class="btn sm" type="button" id="pmBack">← All units</button><b>'+esc(u.name)+'</b><span class="tag">'+esc(PM_KIND[u.kind])+'</span><span class="note mono">'+hx(u.lo)+'–'+hx(u.hi)+' · '+fmt(u.functions)+' functions · '+kB(u.bytes)+'</span>'
+(u.counted?pmBar('Game code in this unit',kB(u.counted),pmCodeSegs(u,u.counted),u.counted):''))}
function pmDrill(i){if(PM.unit==null)PM.topFilter=el('pmFilter').value;PM.unit=i;el('pmFilter').value='';if(el('pmUnits')._filled)el('pmUnits').value=String(i);else pmUnitsSelect();pmTip(null);pmLayout()}
function pmBack(){PM.unit=null;el('pmFilter').value=PM.topFilter||'';if(el('pmUnits')._filled)el('pmUnits').value='';else pmUnitsSelect();pmTip(null);pmLayout()}
function pmUnitsSelect(){const d=PM.doc,s=el('pmUnits'),u=PM.unit!=null?d.units[PM.unit]:null;s._filled=false; /* the full list is built only when someone opens it */
put(s,'<option value="">All units ('+d.units.length+')</option>'+(u?'<option value="'+u.i+'">'+esc(u.name)+'</option>':''));s.value=u?String(u.i):''}
function pmFillUnits(){const d=PM.doc,s=el('pmUnits');if(!d||s._filled)return;s._filled=true;const groups={game:[],unsorted:[],engine:[],library:[]};d.units.forEach(u=>(groups[u.kind]||groups.library).push(u));
put(s,'<option value="">All units ('+d.units.length+')</option>'+[['game','Game code'],['unsorted','Unsorted'],['engine','Engine (Gamebryo)'],['library','Libraries']].map(([k,l])=>groups[k].length?'<optgroup label="'+esc(l)+'">'
+groups[k].sort((a,b)=>a.name.localeCompare(b.name)).map(u=>'<option value="'+u.i+'">'+esc(u.name)+(k==='game'&&u.pct!=null?' · '+Math.floor(u.pct)+'%':'')+'</option>').join('')+'</optgroup>':'').join(''));
s.value=PM.unit==null?'':String(PM.unit)}
(()=>{const c=el('pmMap');let pending=null;document.body.appendChild(el('pmTip')); /* fixed position must be relative to the viewport, not a card */
const hit=(x,y)=>{for(let i=0;i<PM.rects.length;i++){const q=PM.rects[i];if(q&&x>=q[0]&&x<q[0]+q[2]&&y>=q[1]&&y<q[1]+q[3])return i}return -1};
c.addEventListener('mousemove',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);
if(i!==PM.hover){PM.hover=i;if(!pending)pending=requestAnimationFrame(()=>{pending=null;pmBlit()})}pmTip(i<0?null:pmTipHtml(PM.items[i]),e.clientX,e.clientY)});
c.addEventListener('mouseleave',()=>{PM.hover=-1;pmBlit();pmTip(null)});
c.addEventListener('click',e=>{const b=c.getBoundingClientRect(),i=hit(e.clientX-b.left,e.clientY-b.top);if(i<0)return;const it=PM.items[i];
if(it.fns)return pmDrill(it.i);pmTip(null);show('decomp');el('fnTarget').value=hx(it.address);inspectFn();el('h-inspect').scrollIntoView({block:'start'})});
const card=el('pmCard');card.addEventListener('mouseover',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
card.addEventListener('mousemove',e=>{const s=e.target.closest('[data-tip]');if(s)pmTip(esc(s.dataset.tip),e.clientX,e.clientY)});
card.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]'))pmTip(null)});
card.addEventListener('click',e=>{if(e.target.closest('#pmBack'))pmBack()});
['pointerdown','focus','keydown'].forEach(t=>el('pmUnits').addEventListener(t,pmFillUnits));
el('pmUnits').addEventListener('change',e=>{const v=e.target.value;if(v==='')pmBack();else pmDrill(Number(v))});
let ft=0;el('pmFilter').addEventListener('input',()=>{clearTimeout(ft);ft=setTimeout(pmLayout,180)});
el('pmPrev').onclick=()=>pmStep('prev');el('pmNext').onclick=()=>pmStep('next');el('pmLatest').onclick=()=>pmStep('latest');
let rt=0;new ResizeObserver(()=>{clearTimeout(rt);rt=setTimeout(()=>{if(current==='logbook'&&c.clientWidth&&(c.clientWidth!==PM.w||c.clientHeight!==PM.h))pmLayout()},120)}).observe(c); /* only when its size really changes */
const recolor=()=>{PM.colors=null;if(current==='logbook'&&PM.doc)pmPaint()};
new MutationObserver(recolor).observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','data-palette']});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change',recolor);
every(()=>{if(current==='logbook')return pmLoad()},60000,null)})();
el('fnForm').addEventListener('submit',e=>{e.preventDefault();inspectFn()});
el('refreshDecomp').addEventListener('click',renderDecomp);
document.addEventListener('click',e=>{const a=e.target.closest('[data-fn]');if(!a)return;e.preventDefault();if(current!=='decomp')show('decomp');el('fnTarget').value=a.dataset.fn;inspectFn();el('h-inspect').scrollIntoView({behavior:'smooth',block:'start'})});
document.addEventListener('keydown',e=>{if(e.ctrlKey||e.metaKey||e.altKey||e.target.closest('input,textarea,select,[contenteditable]')||document.querySelector('dialog[open]'))return;
const keys=Object.keys(VIEWS).filter(k=>k!=='chat');const i=Number(e.key)-1;if(i>=0&&i<keys.length){e.preventDefault();show(keys[i])}
else if(e.key==='c'){e.preventDefault();show('chat');el('text').focus()}
else if(e.key==='/'){e.preventDefault();show('knowledge');el('q').focus()}
else if(e.key==='Escape')el('shell').classList.remove('open')});
/* The chat section. Its people sit in the sidebar under the board's sections; picking one, or Everyone, opens the
chat filtered to them. Messages keep arriving in every section, so sound, notifications and the unread badge work
wherever you are. */
const Chat=(()=>{
const CENTRAL='America/Chicago';
const log=el('log');
const fmt=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>');
/* one formatter each, made once: building an Intl formatter per message cost milliseconds per render */
const DAY_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,year:'numeric',month:'numeric',day:'numeric'});
const TIME_FMT=new Intl.DateTimeFormat('en-US',{timeZone:CENTRAL,hour:'numeric',minute:'2-digit',timeZoneName:'short'});
const centralDay=d=>DAY_FMT.format(d);
const centralTime=d=>TIME_FMT.format(d);
const since=s=>{if(!s)return 'never';const m=Math.round((Date.now()-new Date(s))/60000);return m<1?'just now':m<120?m+' min ago':m<2880?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago'};
let people=[],byName={},human='User',messages=[],lastId=0,filter='All',thread=null,sending=false,receipts={},overviewData=null,loaded=false;
async function getJSON(path){const r=await fetch(path,{cache:'no-store'});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||('HTTP '+r.status));return j}
const face=(p,state,size)=>'<div class="avatar'+(size?' '+size:'')+'" style="--avatar:'+esc(p.color||'#7b8797')+'"'+(state?' data-state="'+esc(state)+'"':'')+' aria-hidden="true">'+esc(p.initials||String(p.name).slice(0,2))+'</div>';
const display=n=>n===human?'You':n;
const active=()=>current==='chat';
function mountPeople(){
byName=Object.fromEntries(people.map(p=>[p.name,p]));
const live=n=>(overviewData?overviewData.agents.find(a=>a.name===n):null)||{};
put('participants',people.map(p=>{
const a=live(p.name),stopped=!!(a.stopped&&a.stopped.length),trial=a.trial_until&&!stopped;
const fresh=p.last_seen&&(Date.now()-new Date(p.last_seen))<15*60000;
const state=p.kind==='human'?'':stopped?'stopped':a.working?'working':trial?'trial':fresh?'online':'idle';
const status=p.kind==='human'?esc(p.role||'Project owner'):a.working?'<span class="p-work">Working'+(a.last_tool?' · '+esc(a.last_tool):'')+'</span>':stopped?'Stopped':trial?'Trial until '+esc(centralTime(new Date(a.trial_until))):(p.last_seen?'Active '+esc(since(p.last_seen)):'Not seen yet');
const on=active()&&!thread&&filter===p.name;
const tip=[p.role,p.doing,a.working&&a.last_target].filter(Boolean).join(' · ');
return '<button class="person" data-filter="'+esc(p.name)+'"'+(on?' aria-current="page"':'')+' title="'+esc(tip)+'">'+face(p,state,'sm')+'<span class="p-copy"><span class="p-name">'+esc(display(p.name))+'</span><span class="p-sub">'+status+'</span>'+(p.doing&&p.kind!=='human'?'<span class="p-doing">'+esc(p.doing)+'</span>':'')+'</span></button>'}).join(''));
const all=document.querySelector('.person[data-filter="All"]');if(active()&&!thread&&filter==='All')all.setAttribute('aria-current','page');else all.removeAttribute('aria-current');
const sel=el('to').value,agents=people.filter(p=>p.kind!=='human');
const opts=(thread?[['@thread','Thread participants']]:[]).concat([['All','Everyone']],agents.map(p=>[p.name,p.name]));
const key=opts.map(o=>o[0]).join('|');
if(el('to')._key!==key){el('to')._key=key;el('to').replaceChildren(...opts.map(([v,t])=>new Option(t,v)));if([...el('to').options].some(o=>o.value===sel))el('to').value=sel}
}
function parseDate(v){const d=new Date(String(v).replace(' ','T'));return isNaN(d)?null:d}
function dateLabel(d){if(!d)return '';const now=new Date();if(centralDay(d)===centralDay(now))return 'Today';const y=new Date(now-86400000);if(centralDay(d)===centralDay(y))return 'Yesterday';return d.toLocaleDateString('en-US',{timeZone:CENTRAL,weekday:'short',month:'short',day:'numeric'})}
const nearBottom=()=>log.scrollHeight-log.scrollTop-log.clientHeight<90;
function scrollBottom(){log.scrollTop=log.scrollHeight;el('newMessages').hidden=true}
function chips(m){let s='';
if(m.topic&&m.topic!=='general'&&!m.topic.startsWith('legacy:'))s+='<button class="chip" data-thread="'+esc(m.topic)+'" title="'+esc(m.subject||m.topic)+' · open thread">'+esc(m.topic)+'</button>';
if(m.kind&&m.kind!=='note')s+='<span class="chip kind-'+esc(m.kind)+'">'+esc(m.kind)+'</span>';
if(m.importance&&m.importance!=='normal')s+='<span class="chip '+esc(m.importance)+'">'+esc(m.importance)+'</span>';
if(m.ack)s+='<span class="chip" title="Asks for an acknowledgement">ack</span>';
if(m.due_at)s+='<span class="chip high" title="Answer needed by">due '+esc(centralTime(new Date(m.due_at)))+'</span>';
if(m.corrects)s+='<span class="chip fixed">corrects #'+esc(m.corrects)+'</span>';
if(Array.isArray(m.corrected_by))s+='<span class="chip fixed" title="A later message corrects this one">corrected by '+m.corrected_by.map(i=>'#'+esc(i)).join(', ')+'</span>';
if(m.pinned)s+='<span class="chip pin">pinned</span>';return s}
function questionBox(m){const q=m.question;if(!q)return '';
if(q.answer)return '<div class="qbox"><span class="answered">'+ic('check','sm')+'Answered'+(q.answered_by?' by '+esc(display(q.answered_by)):'')+': '+esc(q.answer)+'</span></div>';
if(q.to!==human)return '<div class="qbox"><span class="answered waiting">'+ic('clock','sm')+'Waiting for '+esc(q.to)+'</span></div>';
const opts=(Array.isArray(q.options)?q.options:[]);
return '<div class="qbox">'+opts.map(o=>'<button class="qbtn" data-answer="'+esc(q.id)+'" data-value="'+esc(o)+'">'+esc(o)+'</button>').join('')+'<button class="qbtn" data-answer="'+esc(q.id)+'" data-value="">'+(opts.length?'Other…':'Answer…')+'</button></div>'}
function receiptLine(m){const r=receipts[m.id];if(!r)return '';
const parts=[];if(r.acked.length)parts.push('Acknowledged by '+r.acked.map(display).join(', '));if(r.read.length)parts.push('Seen by '+r.read.map(display).join(', '));if(r.waiting.length)parts.push('Not yet seen: '+r.waiting.map(display).join(', '));
return parts.length?'<span>'+esc(parts.join(' · '))+'</span>':''}
function ackButton(m){if(!(m.ack||m.due_at)||m.from===human)return '';const r=receipts[m.id];if(!(m.recipients||[]).some(x=>x===human||x==='All'))return '';
if(r&&r.acked.includes(human))return '<span>You acknowledged</span>';return '<button class="ackbtn" data-ack="'+m.id+'">Acknowledge</button>'}
function visibleMessages(){return messages.filter(m=>{
if(thread)return m.topic===thread;
return filter==='All'||m.from===filter||(m.recipients||[m.to]).some(r=>r===filter||r==='All')})}
/* The log keeps a bounded window of the newest messages in the DOM (WINDOW rows; "Load earlier" adds WINDOW more).
Rendering all of them (800+ long messages, 10,000 nodes, a 440,000 px column) and rebuilding the whole log on every
new message or receipt froze the page for about two seconds each time. Now new messages are appended, a changed
receipt or mark repaints only its row, and the whole window is rebuilt only when the view changes. */
const WINDOW=150;
let windowSize=WINDOW,shown={key:null,ids:[],sigs:new Map()};
const dayKey=m=>{const d=parseDate(m.at||m.time);return d?centralDay(d):String(m.time)};
const prevOf=(vis,i)=>i>0&&dayKey(vis[i-1])===dayKey(vis[i])?vis[i-1]:null; /* a date line starts a new group */
function rowSig(m){return JSON.stringify([m.corrected_by||null,!!m.pinned,m.question?[m.question.answer,m.question.answered_by]:null,receipts[m.id]||null])}
function dateLine(m){const d=parseDate(m.at||m.time);const dv=document.createElement('div');dv.className='date';dv.innerHTML='<span>'+esc(dateLabel(d)||m.time)+'</span>';return dv}
function buildRow(m,prev,next){
const d=parseDate(m.at||m.time);
const pd=prev?parseDate(prev.at||prev.time):null;
const cont=prev&&prev.from===m.from&&prev.to===m.to&&prev.topic===m.topic&&d&&pd&&d-pd<300000&&!m.ack;
const nd=next?parseDate(next.at||next.time):null;
const last=!next||next.from!==m.from||next.to!==m.to||next.topic!==m.topic||!nd||!d||nd-d>=300000||next.ack;
const p=byName[m.from]||{name:m.from,initials:String(m.from).slice(0,2)};const own=m.from===human;
const row=document.createElement('div');row.className='mrow'+(own?' own':'')+(cont?' cont':'');row.dataset.id=m.id;
const to=(m.recipients||[m.to]).map(r=>r===human?'you':r==='All'?'everyone':r).join(', ');
if(!Array.isArray(m.attachments))m.attachments=[];
const files=m.attachments.length?'<div class="files">'+m.attachments.map(f=>'<a href="files/'+encodeURIComponent(f)+'">'+ic('file','sm')+esc(f.slice(13))+'</a>').join('')+'</div>':'';
const pinBtn='<button class="linkbtn" data-pin="'+m.id+'" data-unpin="'+(m.pinned?1:'')+'">'+(m.pinned?'Unpin':'Pin')+'</button>';
const foot=last||m.ack||m.pinned?'<div class="foot"><span title="#'+m.id+' · '+esc(m.time)+'">'+esc(d?centralTime(d):m.time)+'</span>'+(own?receiptLine(m):'')+ackButton(m)+pinBtn+'</div>':'';
row.innerHTML=face(p)+'<div class="stack"><div class="meta"><strong>'+esc(display(m.from))+'</strong><span class="arrow">→</span>'+esc(to)+chips(m)+'</div><div class="bubble'+(m.importance==='urgent'?' urgent':'')+'">'+fmt(m.text)+files+questionBox(m)+'</div>'+foot+'</div>';
return row}
/* the first row in view and its offset, to keep the reader's place across a rebuild */
function topRow(){const st=log.scrollTop;for(const r of log.querySelectorAll('.mrow'))if(r.offsetTop+r.offsetHeight>st)return {id:r.dataset.id,off:r.offsetTop-st};return null}
function renderLog(force=false,preserve=false){
const all=visibleMessages(),key=JSON.stringify([filter,thread]);
if(!force&&!preserve&&shown.key===key&&shown.ids.length){
const start=all.findIndex(m=>m.id===shown.ids[0]),vis=start<0?null:all.slice(start),n=shown.ids.length;
const sameHead=vis&&vis.length>=n&&shown.ids.every((id,i)=>vis[i].id===id);
/* append in place, unless the window has grown far past its size and the reader is at the bottom (then trim) */
if(sameHead&&!(vis.length>windowSize+WINDOW&&nearBottom()))return patchLog(vis)}
const atBottom=!preserve&&(force||nearBottom()||shown.key===null);const anchor=atBottom?null:topRow();
const vis=all.slice(-windowSize),frag=document.createDocumentFragment();let day='';
if(!vis.length){const e=document.createElement('div');e.className='empty';e.innerHTML=ic(thread?'thread':'chat')+'<strong>Nothing here yet</strong><span>'+(thread?'This thread has no recent messages; search in Knowledge for older ones.':'Messages will appear here.')+'</span>';frag.appendChild(e)}
vis.forEach((m,i)=>{const k=dayKey(m);if(k!==day){frag.appendChild(dateLine(m));day=k}frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]))});
if(all.length>vis.length||(!thread&&messages.length&&messages[0].id>1)){const b=document.createElement('button');b.className='earlier';b.id='earlier';b.innerHTML=ic('clock','sm')+'Load earlier messages';frag.prepend(b)}
log.replaceChildren(frag);
shown={key,ids:vis.map(m=>m.id),sigs:new Map(vis.map(m=>[m.id,rowSig(m)]))};
if(atBottom)scrollBottom();
else if(anchor){const r=log.querySelector('.mrow[data-id="'+anchor.id+'"]');if(r)log.scrollTop=r.offsetTop-anchor.off}
}
function patchLog(vis){
const n=shown.ids.length,added=vis.length-n,redo=new Set();
for(let i=0;i<n;i++)if(shown.sigs.get(vis[i].id)!==rowSig(vis[i]))redo.add(i);
if(!added&&!redo.size)return;
const atBottom=nearBottom();
if(added)redo.add(n-1); /* the old last row may lose its footer or join the new group */
for(const i of redo){const m=vis[i],node=log.querySelector('.mrow[data-id="'+m.id+'"]');
if(node)node.replaceWith(buildRow(m,prevOf(vis,i),vis[i+1]));shown.sigs.set(m.id,rowSig(m))}
if(added){const frag=document.createDocumentFragment();
for(let i=n;i<vis.length;i++){const m=vis[i];if(dayKey(m)!==dayKey(vis[i-1]))frag.appendChild(dateLine(m));
frag.appendChild(buildRow(m,prevOf(vis,i),vis[i+1]));shown.ids.push(m.id);shown.sigs.set(m.id,rowSig(m))}
log.appendChild(frag);
if(atBottom)scrollBottom();else el('newMessages').hidden=false}
}
function markRead(){const me=overviewData&&overviewData.agents.find(a=>a.name===human);if(me&&me.unread)post('api/read',{}).then(()=>refresh()).catch(()=>{})}
function view(){mountPeople();if(!active())return;updateHeader();if(loaded)renderLog(true);if(!document.hidden&&document.hasFocus())markRead()}
function setFilter(v){filter=v;thread=null;windowSize=WINDOW;el('shell').classList.remove('open');if(active())view();else show('chat')}
function setThread(t){thread=t;windowSize=WINDOW;if(active())view();else show('chat');el('to').value='@thread'}
function updateHeader(){
el('threadbar').classList.toggle('on',!!thread);
if(thread){const m=messages.find(x=>x.topic===thread);el('threadName').textContent=thread+(m&&m.subject&&m.subject!==thread?' · '+m.subject:'')}
if(!active())return;
const icon=el('viewIcon'),person=!thread&&filter!=='All'&&byName[filter];
icon.className='view-icon'+(person?' is-face':'');put(icon,person?face(byName[filter]):ic(thread?'thread':'chat'));
if(thread){el('viewTitle').textContent='Thread';el('viewSub').textContent=thread}
else{const busy=overviewData?overviewData.agents.filter(a=>a.working).map(a=>a.name):[];
el('viewTitle').textContent=filter==='All'?'Everyone':'With '+display(filter);
el('viewSub').textContent=busy.length?(busy.join(', ')+(busy.length>1?' are':' is')+' working…'):(filter==='All'?(people.filter(p=>p.kind!=='human').length+' agents · you · one shared conversation'):'Includes messages to everyone')}
}
let audioCtx=null;
function beep(){try{audioCtx=audioCtx||new (window.AudioContext||window.webkitAudioContext)();const o=audioCtx.createOscillator(),g=audioCtx.createGain();o.type='sine';o.frequency.value=880;g.gain.setValueAtTime(0.0001,audioCtx.currentTime);g.gain.exponentialRampToValueAtTime(0.15,audioCtx.currentTime+0.02);g.gain.exponentialRampToValueAtTime(0.0001,audioCtx.currentTime+0.35);o.connect(g).connect(audioCtx.destination);o.start();o.stop(audioCtx.currentTime+0.4)}catch(e){}}
function speak(m){try{if(!('speechSynthesis' in window))return beep();const u=new SpeechSynthesisUtterance(display(m.from)+(m.importance==='urgent'?' says, urgently: ':' says: ')+String(m.text).slice(0,260));u.rate=1.05;speechSynthesis.speak(u)}catch(e){}}
const SOUND_MODES=['off','beep','speak'],SOUND_LABEL={off:'Sound off',beep:'Sound: beep',speak:'Read aloud'},SOUND_ICON={off:'volume-off',beep:'volume',speak:'speak'};
function paintSound(){const s=store.get('sound','off');const b=el('sound');b.innerHTML=ic(SOUND_ICON[s]||'volume-off')+'<span class="lbl">'+esc(SOUND_LABEL[s]||'Sound off')+'</span>';b.setAttribute('aria-label',SOUND_LABEL[s]||'Sound off');b.setAttribute('aria-pressed',String(s!=='off'))}
function soundFor(fresh){const s=store.get('sound','off');if(s==='off')return;const mine=fresh.filter(m=>m.from!==human&&((m.recipients||[]).includes(human)||m.importance==='urgent'||(m.question&&m.question.to===human)));
if(!mine.length)return;if(s==='beep')beep();else mine.slice(-2).forEach(speak)}
function notifyFor(fresh){soundFor(fresh);
const mine=fresh.filter(m=>m.from!==human&&((m.recipients||[]).includes(human)||m.importance==='urgent'));
if(document.hidden){unseen+=fresh.filter(m=>m.from!==human).length;paintTitle()}
if(!mine.length||!store.get('notify',false)||!('Notification' in window)||Notification.permission!=='granted'||!document.hidden)return;
for(const m of mine.slice(-3)){try{new Notification(display(m.from)+(m.importance==='urgent'?' (urgent)':''),{body:m.text.slice(0,180),tag:'harbor-'+m.id})}catch(e){}}
}
async function pollMessages(){
try{const fresh=await getJSON(lastId?'messages?after='+lastId:'messages');
if(!lastId||fresh.length){if(lastId)notifyFor(fresh);messages=lastId?messages.concat(fresh).slice(-2000):fresh;lastId=messages.reduce((n,m)=>Math.max(n,m.id||0),lastId);loaded=true;if(active())renderLog()}}
catch(e){}
}
async function pollPeople(){
try{overviewData=data||await getJSON('api/overview');people=await getJSON('participants');human=overviewData.project.human;
const ais=overviewData.agents.filter(a=>a.kind!=='human');const stop=ais.filter(a=>a.stopped&&a.stopped.length).map(a=>a.name);
el('stopbar').classList.toggle('on',stop.length>0);
put('stopbar',stop.length?ic('stop')+'<span>Stopped: '+esc(stop.length===ais.length?'everyone':stop.join(', '))+'</span><button type="button" data-go="launch">Resume them from Launch</button>':'');
mountPeople();if(!thread)updateHeader();
const ids=messages.filter(m=>m.from===human||m.ack||m.due_at).slice(-40).map(m=>m.id);
if(ids.length){receipts=await getJSON('api/receipts?ids='+ids.join(','));if(active())renderLog()}
if(active()&&!document.hidden&&document.hasFocus())markRead();
}catch(e){}
}
const box=el('text');if(matchMedia('(max-width: 680px)').matches)box.placeholder='Message the team… /help for commands';
function resizeBox(){box.style.height='auto';box.style.height=Math.min(box.scrollHeight,180)+'px'}box.addEventListener('input',resizeBox);
const HELP=`/stop <Agent|all> [reason] stop someone (or everyone)
/resume <Agent|all> resume
/away [8h|2h|tomorrow] [note] tell the team you are away
/dnd [time] [note] /here do not disturb / back
/ping <Agent> urgent "are you there?"
/task <title> [@Agent] [!high|!urgent|!low] [fn:0x...] add a task
/pin <message id> pin a message
/status who is working, stopped, waiting
/find <words> search every message (Knowledge)`;
function untilFrom(word){if(!word)return null;if(word==='tomorrow'){const d=new Date();d.setDate(d.getDate()+1);d.setHours(8,0,0,0);return d.toISOString().replace(/\.\d+Z$/,'Z')}return /^\d+(\.\d+)?[mhd]$/.test(word)?word:null}
const BARE=new Set(['help','?','here','back','status']);
async function slash(text){const [cmd,...rest]=text.slice(1).split(/\s+/);const arg=rest.join(' ');
if(BARE.has((cmd||'').toLowerCase())&&rest.length)return false; /* "/here is the log" is a message */
const say=(msg,err)=>{el('feedback').className=err?'error':'';el('feedback').textContent=msg};
const agentName=n=>{const p=people.find(x=>x.name.toLowerCase()===String(n||'').toLowerCase()&&x.kind!=='human');return p?p.name:(String(n).toLowerCase()==='all'?'all':null)};
switch((cmd||'').toLowerCase()){
case 'help':case '?':el('feedback').className='cmdhelp';el('feedback').textContent=HELP;return true;
case 'stop':{const who=agentName(rest[0]);if(!who)return say('Who? /stop <Agent|all> [reason]',1),true;await post('api/flag',{target:who,value:'on',reason:rest.slice(1).join(' ')||null});refresh();say('Stopped '+who+'.');return true}
case 'resume':{const who=agentName(rest[0]);if(!who)return say('Who? /resume <Agent|all>',1),true;if(!confirm('Resume '+who+'?'))return true;await post('api/flag',{target:who,value:'off',reason:rest.slice(1).join(' ')||null});refresh();say('Resumed '+who+'.');return true}
case 'away':case 'dnd':{const until=untilFrom(rest[0]);const note=(until?rest.slice(1):rest).join(' ')||null;await post('api/presence',{status:cmd.toLowerCase()==='dnd'?'dnd':'away',until,note});await refresh();await pollPeople();say('The team now sees you as '+(cmd.toLowerCase()==='dnd'?'do not disturb':'away')+(until?' until '+(rest[0]==='tomorrow'?'tomorrow 8 AM':rest[0]):'')+'.');return true}
case 'here':case 'back':await post('api/presence',{status:'here'});await refresh();await pollPeople();say('Welcome back.');return true;
case 'ping':{const who=agentName(rest[0]);if(!who||who==='all')return say('Who? /ping <Agent>',1),true;await post('api/ping',{agent:who});say('Pinged '+who+'.');return true}
case 'task':{let assignee=null,priority='normal',scope=null;const words=[];for(const w of rest){if(w.startsWith('@')&&agentName(w.slice(1)))assignee=agentName(w.slice(1));else if(/^!(urgent|high|normal|low)$/i.test(w))priority=w.slice(1).toLowerCase();else if(/^(fn:|range:|file:|name:)/i.test(w)||/^0x[0-9a-f]+$/i.test(w))scope=w;else words.push(w)}
if(!words.length)return say('/task <title> [@Agent] [!high] [fn:0x...]',1),true;const r=await post('api/task',{action:'add',title:words.join(' '),assignee,priority,scope});refresh();say('Added task #'+r.task.id+(assignee?' for '+assignee:'')+'.');return true}
case 'pin':{const id=Number(rest[0]);if(!id)return say('/pin <message id>',1),true;await post('api/pin',{id});await refreshMarks();say('Pinned #'+id+'.');return true}
case 'status':{const o=await getJSON('api/overview');const stopped=o.agents.filter(a=>a.stopped&&a.stopped.length).map(a=>a.name);const working=o.agents.filter(a=>a.working).map(a=>a.name);
say((working.length?'Working: '+working.join(', ')+'. ':'Nobody is working right now. ')+(stopped.length?'Stopped: '+stopped.join(', ')+'. ':'')+o.tasks.length+' open tasks, '+o.queue.length+' in review, '+o.questions.length+' questions for you.');return true}
case 'find':case 'search':show('knowledge');el('q').value=arg;if(arg)el('searchForm').requestSubmit();return true;
default:return false}}
el('composer').addEventListener('submit',async e=>{e.preventDefault();const text=box.value.trim();if(!text||sending)return;
if(text.startsWith('/')&&!text.startsWith('//')){try{if(await slash(text)){box.value='';resizeBox();return}}catch(err){el('feedback').className='error';el('feedback').textContent=err.message;return}}
if(recognizer)recognizer.stop();
sending=true;el('send').disabled=true;
try{const body={text,ack:el('askAck').checked,importance:el('urgent').checked?'urgent':'normal'};const to=el('to').value;
if(to==='@thread'&&thread){const last=messages.filter(m=>m.topic===thread).pop();body.thread=thread;if(last)body.reply_to=last.id;else body.to='All'}else{body.to=to;if(thread)body.thread=thread}
const reply=await post('send',body);box.value='';resizeBox();el('askAck').checked=false;el('urgent').checked=false;
el('feedback').className='';el('feedback').textContent=reply.warnings&&reply.warnings.length?'Sent · note: '+reply.warnings.join('; '):'Sent to '+reply.targets.map(display).join(', ');
await pollMessages();scrollBottom()}
catch(err){el('feedback').className='error';el('feedback').textContent='Not sent ('+err.message+'). Your text is kept; try again.'}
finally{sending=false;el('send').disabled=false;box.focus()}});
box.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing){e.preventDefault();el('composer').requestSubmit()}});
document.addEventListener('click',async e=>{
const f=e.target.closest('[data-filter]');if(f)return setFilter(f.dataset.filter);
if(!e.target.closest('#view-chat'))return;
const t=e.target.closest('[data-thread]');if(t)return setThread(t.dataset.thread);
if(e.target.closest('#earlier'))return loadEarlier();
const qk=e.target.closest('[data-quick]');if(qk){try{await quick(qk.dataset.quick)}catch(err){alert(err.message)}return}
const ans=e.target.closest('[data-answer]');if(ans){let value=ans.dataset.value;if(!value){value=prompt('Your answer');if(!value)return;value=(ans.parentElement.querySelectorAll('[data-value]').length>1?'other: ':'')+value}
ans.disabled=true;try{await post('api/answer',{question:Number(ans.dataset.answer),answer:value});await refreshMarks();refresh();el('feedback').className='';el('feedback').textContent='Answer sent.'}catch(err){ans.disabled=false;alert(err.message)}return}
const pb=e.target.closest('[data-pin]');if(pb){try{await post('api/pin',{id:Number(pb.dataset.pin),unpin:!!pb.dataset.unpin});await refreshMarks()}catch(err){alert(err.message)}return}
const a=e.target.closest('[data-ack]');if(a){a.disabled=true;try{await post('api/ack',{ids:[Number(a.dataset.ack)]});await pollPeople();refresh()}catch(err){a.disabled=false;alert(err.message)}}});
/* show WINDOW more rows: from memory first, from the server once everything loaded is on screen */
async function loadEarlier(){const first=messages[0];if(!first)return;
if(visibleMessages().length<=shown.ids.length){
try{const older=await getJSON('messages?before='+first.id);if(!older.length){el('earlier')&&el('earlier').remove();return}
messages=older.concat(messages)}catch(e){return}}
windowSize=shown.ids.length+WINDOW;renderLog(false,true)}
async function refreshMarks(){if(!messages.length||!active())return;try{
const tail=await getJSON('messages?before='+(messages[messages.length-1].id+1));const byId=new Map(tail.map(m=>[m.id,m]));
let changed=false;for(const m of messages){const n=byId.get(m.id);if(n&&(JSON.stringify(n.corrected_by)!==JSON.stringify(m.corrected_by)||!!n.pinned!==!!m.pinned||JSON.stringify(n.question)!==JSON.stringify(m.question))){m.corrected_by=n.corrected_by;m.pinned=n.pinned;m.question=n.question;changed=true}}
if(changed&&active())renderLog()}catch(e){}}
async function catchUp(sinceWhen,title){
let d,o;try{[d,o]=await Promise.all([getJSON('api/digest?since='+encodeURIComponent(sinceWhen)),getJSON('api/overview')])}catch(e){return}
const li=a=>a.length?'<ul>'+a.join('')+'</ul>':'<div class="note">Nothing.</div>';
const team=d.agents.filter(a=>a.name!==human);
const work=team.map(a=>{const ov=o.agents.find(x=>x.name===a.name)||{};const bits=[];if(a.messages)bits.push(a.messages+' messages');if(a.submitted)bits.push(a.submitted+' submitted');if(a.reviewed)bits.push(a.reviewed+' reviewed');if(a.landed)bits.push(a.landed+' landed');
return '<li>'+face(byName[a.name]||{name:a.name},ov.stopped&&ov.stopped.length?'stopped':ov.working?'working':'')+'<span><b>'+esc(a.name)+'</b>: '+(bits.join(', ')||'quiet')+(ov.stopped&&ov.stopped.length?' · stopped':'')+(a.doing?' · now: '+esc(a.doing):'')+(ov.handoff?' · handoff '+esc(since(ov.handoff.at)):'')+'</span></li>'});
const wins=(o.wins||[]).map(w=>'<li><span class="tag green">landed</span><span>'+esc(w.title)+' <span class="note">by '+esc(w.author)+'</span></span></li>');
const qs=(o.questions||[]).map(q=>'<li><span class="tag blue">'+esc(q.asker)+'</span><span>'+esc(q.question)+'</span></li>');
const att=(o.attention||[]).map(a=>'<li><span class="tag amber">attention</span><span>'+esc(a.text)+'</span></li>');
const tasks=(o.tasks||[]).filter(t=>t.state!=='open').map(t=>'<li><span class="tag violet">#'+t.id+' '+esc(t.state.replace('_',' '))+'</span><span>'+esc(t.assignee||'')+' · '+esc(t.title)+'</span></li>');
el('catchTitle').textContent=title;
el('catchBody').innerHTML='<div class="note">Since '+esc(centralTime(new Date(d.since)))+'</div><h3 class="sub-h">The team</h3>'+li(work)+'<h3 class="sub-h">Landed</h3>'+li(wins)+'<h3 class="sub-h">Questions for you</h3>'+li(qs)+'<h3 class="sub-h">Needs attention</h3>'+li(att)+'<h3 class="sub-h">Work in progress</h3>'+li(tasks);
el('catchup').showModal()}
el('catchClose').onclick=()=>el('catchup').close();
el('askStatus').onclick=async()=>{const running=(overviewData?overviewData.agents:[]).filter(a=>a.kind!=='human'&&!(a.stopped&&a.stopped.length)).map(a=>a.name);
if(!running.length)return alert('Every agent is stopped; nobody would answer.');
try{await post('send',{to:running.join(','),text:'Status please: reply in this thread with what you finished, what is in flight, and what comes next. Keep it short.',thread:'status',ack:true});el('catchup').close();el('feedback').textContent='Asked '+running.join(', ')+' for a status.'}catch(err){alert(err.message)}};
async function quick(kind){
if(kind==='night'){const lead=(overviewData&&overviewData.project.coordinators||[])[0]||'the coordinator';
const text='Heading out for the night. I am away until 8 AM: keep working on your tasks, decide with '+lead+' instead of waiting for me, and write a handoff before you stop.';
if(!confirm('Send to everyone and mark you away until 8 AM?\n\n'+text))return;
const d=new Date();if(d.getHours()>=8)d.setDate(d.getDate()+1);d.setHours(8,0,0,0);
await post('api/presence',{status:'away',until:d.toISOString().replace(/\.\d+Z$/,'Z'),note:'decide with '+lead+', do not wait for me'});
await post('send',{to:'All',text});await pollMessages();await refresh();await pollPeople();el('feedback').textContent='Good night. The team sees you away until 8 AM.';return}
if(kind==='morning'){const p=overviewData&&overviewData.presence;const from=p&&p.set_at&&(p.status!=='here'||p.expired)?p.set_at:'12h';
await post('api/presence',{status:'here'});await refresh();await pollPeople();return catchUp(from,'Good morning - while you were away')}
if(kind==='status')return catchUp('12h','Status: the last 12 hours')}
let recognizer=null;
el('mic').onclick=()=>{const SR=window.SpeechRecognition||window.webkitSpeechRecognition;if(!SR)return alert('This browser has no speech recognition; Chrome or Edge do.');
if(recognizer){recognizer.stop();return}
recognizer=new SR();recognizer.lang='en-US';recognizer.interimResults=true;recognizer.continuous=true;const start=box.value;
recognizer.onresult=e=>{let t='';for(const r of e.results)t+=r[0].transcript;box.value=(start?start+' ':'')+t.trim();resizeBox()};
recognizer.onend=()=>{recognizer=null;el('mic').classList.remove('on');box.focus()};recognizer.onerror=()=>{};
recognizer.start();el('mic').classList.add('on')};
el('leaveThread').onclick=()=>setFilter('All');
el('settleChatThread').onclick=async()=>{if(!thread)return;try{await post('api/thread-state',{thread,state:'closed'});el('feedback').textContent='Thread closed; a new message reopens it.'}catch(err){alert(err.message)}};
el('newMessages').addEventListener('click',scrollBottom);log.addEventListener('scroll',()=>{if(nearBottom())el('newMessages').hidden=true});
el('sound').onclick=()=>{const s=store.get('sound','off');const next=SOUND_MODES[(SOUND_MODES.indexOf(s)+1)%SOUND_MODES.length];store.set('sound',next);paintSound();if(next==='beep')beep();if(next==='speak')speak({from:'Harbor',text:'Messages to you will be read aloud.',importance:'normal'})};
function paintNotify(){const on=store.get('notify',false)&&('Notification' in window)&&Notification.permission==='granted';const b=el('notify');b.setAttribute('aria-pressed',String(on));b.innerHTML=ic(on?'bell':'bell-off')+'<span class="lbl">'+(on?'Notify on':'Notify')+'</span>';b.setAttribute('aria-label',on?'Desktop notifications on':'Desktop notifications off')}
el('notify').onclick=async()=>{if(!('Notification' in window))return alert('This browser has no desktop notifications.');
if(store.get('notify',false)&&Notification.permission==='granted'){store.set('notify',false)}else{const p=await Notification.requestPermission();store.set('notify',p==='granted')}paintNotify()};
window.addEventListener('focus',()=>{if(active())markRead()});
paintNotify();paintSound();
(async()=>{await pollPeople();await pollMessages();if(active())view();
every(pollMessages,2000,10000);every(pollPeople,6000,60000);every(refreshMarks,20000,null)})(); /* hidden: still poll for sound and notifications, slower */
return {view,openThread:setThread};
})();
el('pulseHours').onchange=renderPulse;
document.addEventListener('visibilitychange',()=>{if(!document.hidden){unseen=0;paintTitle()}});
const startQ=new URLSearchParams(location.search).get('q');
const onBoard=/\/board\/?$/.test(location.pathname);
show(startQ?'knowledge':(location.hash.slice(1)||(onBoard?store.get('board.view','overview'):'chat')));
refresh().then(()=>{renderDigest();if(current==='activity'){renderPulse();renderFeed()}if(current==='launch')renderLaunch();if(current==='decomp')renderDecomp();if(current==='logbook')renderLogbook();if(current==='health')renderTools();if(startQ){el('q').value=startQ;el('searchForm').requestSubmit()}});
every(refresh,3000,30000);
every(()=>{if(current==='overview')return renderDigest()},30000,null);
every(()=>{if(current==='activity')return renderPulse()},60000,null);
every(()=>{if(current==='launch')return renderLaunch()},5000,null);
every(()=>{if(current==='activity')return renderFeed()},3000,null);
</script>
</body>
</html>