apps › Dashboard · version 1 ·

Open the app

---
description: the Pro dashboard — a project's home page after onboarding (Spec, Design, Test counts, features, the pipeline in motion, tests, apps, needs-you, changes, your AI, gaps, data, a thought box with the brain)
tags: template, pro, inherited
---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@600;700;800&display=swap" rel="stylesheet">
<style>
/* Layout: what needs the person first, then spec / design / test as the headline, then two working columns
   (features left, tests and gaps right), the pipeline full width, and the quieter panels in threes. d2's own tokens. */
:root{--paper:#fbfaf7;--card:#ffffff;--ink:#1d1d1b;--soft:#6b6a66;--line:#e4e1da;--accent:#9a4b12;--accent-bg:#f5e8dc;--slate-bg:#f3f1ec;--sun:#d99a2b;--sun-bg:#fbf1dc;--leaf:#3f7d4e;--leaf-bg:#e6f1e8;--rose:#b23a48;--rose-bg:#f8e6e6;--attn:#7a7fc9;--attn-bg:#ecedf8;
  --display:'Manrope',system-ui,-apple-system,'Segoe UI',sans-serif;--body:system-ui,-apple-system,'Segoe UI',sans-serif;--mono:ui-monospace,Menlo,Consolas,monospace}
@media (prefers-color-scheme:dark){:root{--paper:#141413;--card:#1d1d1b;--ink:#ecebe6;--soft:#9c9a93;--line:#33322f;--accent:#f0a35e;--accent-bg:#2e2419;--slate-bg:#22211f;--sun:#e5b454;--sun-bg:#2c2515;--leaf:#6cc08a;--leaf-bg:#1b2a20;--rose:#f08a92;--rose-bg:#3a2022;--attn:#9296f5;--attn-bg:#22233a;color-scheme:dark}}
*{box-sizing:border-box}
body{background:var(--paper);color:var(--ink);font:15px/1.5 var(--body)}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
button,input,textarea{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--attn);outline-offset:2px}
.bar{border-bottom:1px solid var(--line);background:var(--card)}
.bar .in{display:flex;flex-wrap:wrap;align-items:center;gap:6px 18px;padding-block:9px}
.logo{font:800 17px var(--display);letter-spacing:-.02em}
.bar nav{display:flex;flex-wrap:wrap;gap:4px 16px;color:var(--soft);font-size:14px}
.bar nav b{color:var(--ink)}
.bar .r{margin-left:auto;display:flex;gap:10px;align-items:center}
.pill{font:600 12px var(--body);padding:2px 9px;border-radius:999px;background:var(--sun-bg);color:var(--ink);border:1px solid var(--sun)}
.av{width:28px;height:28px;border-radius:50%;background:var(--accent-bg);color:var(--accent);display:grid;place-items:center;font:700 13px var(--display)}
.in{max-width:1180px;margin:0 auto;padding-inline:16px}
main.in{padding-block:18px 40px;display:grid;gap:18px}
.mock{font-size:13px;color:var(--soft);background:var(--slate-bg);border-radius:8px;padding:8px 12px}
.mock b{color:var(--ink)}
.head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}
h1{font:800 clamp(24px,3.4vw,32px)/1.1 var(--display);letter-spacing:-.025em;margin:0}
h2{font:700 15px var(--display);letter-spacing:-.005em;margin:0;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
h2 small{font:400 13px var(--body);color:var(--soft)}
h2 .more{margin-left:auto;font:400 13px var(--body)}
.tag{font:600 11px var(--body);letter-spacing:.04em;text-transform:uppercase;padding:2px 7px;border-radius:6px;background:var(--slate-bg);color:var(--soft);white-space:nowrap}
.tag.q{background:var(--attn-bg);color:var(--attn)}.tag.d{background:var(--sun-bg);color:var(--ink)}.tag.a{background:var(--accent-bg);color:var(--accent)}.tag.bad{background:var(--rose-bg);color:var(--rose)}.tag.ok{background:var(--leaf-bg);color:var(--leaf)}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;display:grid;gap:10px;align-content:start;min-width:0}
.cols{display:grid;gap:18px;grid-template-columns:repeat(12,minmax(0,1fr))}
.c12{grid-column:span 12}.c8{grid-column:span 8}.c7{grid-column:span 7}.c5{grid-column:span 5}.c4{grid-column:span 4}
@media (max-width:860px){.c8,.c7,.c5,.c4{grid-column:span 12}}
ul{list-style:none;margin:0;padding:0}
.rows li{display:flex;gap:10px;align-items:baseline;padding:7px 0;border-top:1px solid var(--line);flex-wrap:wrap}
.rows li:first-child{border-top:0}
.rows .t{flex:1 1 220px;min-width:0}
.rows .w{color:var(--soft);font-size:13px;white-space:nowrap}
.btn{font:600 13px var(--body);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:3px 10px;cursor:pointer;color:var(--ink)}
.btn:hover{border-color:var(--accent)}.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--card)}
/* needs you */
.needs{border-color:var(--attn);box-shadow:inset 3px 0 0 var(--attn)}
.count{font:800 13px var(--display);background:var(--attn);color:var(--card);border-radius:999px;padding:1px 9px}
/* headline tiles */
.sdt{display:grid;gap:18px;grid-template-columns:repeat(3,minmax(0,1fr))}
@media (max-width:860px){.sdt{grid-template-columns:1fr}}
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px;display:grid;gap:6px;color:var(--ink);min-width:0}
.tile:hover{text-decoration:none;border-color:var(--accent)}
.tile .k{font:700 12px var(--display);letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
.tile .n{font:800 34px/1 var(--display);letter-spacing:-.03em;font-variant-numeric:tabular-nums}
.tile .n small{font:600 15px var(--body);letter-spacing:0;color:var(--soft)}
.tile .s{color:var(--soft);font-size:13px}
.seg{display:flex;height:8px;border-radius:999px;overflow:hidden;background:var(--slate-bg)}
.seg i{display:block}.seg .p{background:var(--leaf)}.seg .f{background:var(--rose)}.seg .n0{background:var(--sun)}
.key{display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--soft)}
.key i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px}
/* features */
.search{width:100%;border:1px solid var(--line);border-radius:999px;background:var(--paper);padding:7px 14px}
.feat{display:grid;grid-template-columns:minmax(0,1fr) 92px 110px;gap:4px 12px;align-items:center;padding:8px 0;border-top:1px solid var(--line)}
.feat:first-child{border-top:0}
.feat .nm{font-weight:600;min-width:0}.feat .nm code{font:12px var(--mono);color:var(--soft);margin-left:6px}
.feat .num{font-variant-numeric:tabular-nums;font-size:13px;color:var(--soft);text-align:right}
.feat .beh{grid-column:1/-1;font-size:13px;color:var(--soft);display:grid;gap:2px;padding-left:2px}
.feat .beh code{font:12px var(--mono);color:var(--accent)}
.feat mark{background:var(--sun-bg);color:var(--ink);border-radius:3px}
@media (max-width:520px){.feat{grid-template-columns:minmax(0,1fr) 70px}.feat .seg{grid-column:1/-1}}
.none{color:var(--soft);font-size:13px;padding:8px 0}
/* pipeline */
.lanes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}
@media (max-width:700px){.lanes{grid-template-columns:1fr}}
.lane{background:var(--paper);border:1px dashed var(--line);border-radius:10px;padding:8px;display:grid;gap:6px;align-content:start;min-height:112px}
@media (max-width:700px){.lane{min-height:0}}
.lane h3{font:700 12px var(--display);letter-spacing:.06em;text-transform:uppercase;color:var(--soft);margin:0;display:flex;justify-content:space-between}
.tk{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:7px;padding:5px 8px;font-size:13px;line-height:1.3;animation:in .5s ease}
.tk code{font:11px var(--mono);color:var(--soft);display:block}
.tk.you{border-left-color:var(--attn)}.tk.done{border-left-color:var(--leaf);color:var(--soft)}
@keyframes in{from{transform:translateX(-14px);opacity:.2}to{transform:none;opacity:1}}
@media (prefers-reduced-motion:reduce){.tk{animation:none}}
/* small things */
.apps{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.app{border:1px solid var(--line);border-radius:10px;padding:10px 12px;display:grid;gap:2px;color:var(--ink);background:var(--paper)}
.app b{font:700 14px var(--display)}.app span{font-size:12px;color:var(--soft)}
.meter{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;font-size:13px;align-items:center}
.meter .seg{grid-column:1/-1;height:6px}.meter .seg i{background:var(--accent)}
.meter .v{color:var(--soft);font-variant-numeric:tabular-nums}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--leaf);margin-right:6px}
.dot.idle{background:var(--sun)}
textarea{width:100%;min-height:74px;border:1px solid var(--line);border-radius:10px;background:var(--paper);padding:9px 12px;resize:vertical;caret-color:var(--attn)}
.said{font-size:13px;color:var(--leaf)}
footer{border-top:1px solid var(--line);color:var(--soft);font-size:12px;text-align:center;padding-block:14px}

body{margin:0}
.wait{color:var(--soft);font-size:13px;background:var(--slate-bg);border-radius:8px;padding:8px 12px}
.feat{cursor:pointer}.feat .beh span{display:block}
.st{font:600 11px var(--body);padding:1px 6px;border-radius:5px;margin-left:6px;white-space:nowrap}
.st.tested{background:var(--leaf-bg);color:var(--leaf)}.st.planned{background:var(--sun-bg);color:var(--ink)}.st.none{background:var(--slate-bg);color:var(--soft)}.st.failing{background:var(--rose-bg);color:var(--rose)}
.seg .n1{background:var(--line)}
.think{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.15fr);gap:16px;align-items:stretch}
@media (max-width:760px){.think{grid-template-columns:1fr}}
.think form{display:flex;flex-direction:column;min-width:0}
.think textarea{flex:1;min-height:230px}
.brain{position:relative;min-width:0;border:1px solid var(--line);border-radius:10px;background:var(--paper);overflow:hidden;display:flex;flex-direction:column}
.brain canvas{display:block;width:100%;height:270px;cursor:pointer}
.bcap{margin:0;padding:7px 12px;border-top:1px solid var(--line);font-size:13px;color:var(--soft);min-height:2.9em}
.bcap b{color:var(--ink)}
.think form{position:relative}
.pop{position:absolute;z-index:5;min-width:230px;max-width:min(340px,92%);background:var(--card);border:1px solid var(--line);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.14);padding:4px;font-size:13px}
.pop .o{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:5px 8px;border-radius:7px;cursor:pointer;align-items:baseline}
.pop .o b{font-weight:600;color:var(--ink)}.pop .o b u{text-decoration:none;color:var(--accent)}
.pop .o .k{font:600 10px var(--body);letter-spacing:.05em;text-transform:uppercase;color:var(--soft)}
.pop .o .d{grid-column:1/-1;color:var(--soft);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pop .o.on{background:var(--accent-bg)}
.pop .f{padding:4px 8px 2px;color:var(--soft);font-size:11px;border-top:1px solid var(--line);margin-top:3px}
.pop kbd{font:600 10px var(--mono);border:1px solid var(--line);border-radius:4px;padding:0 4px;background:var(--paper);color:var(--ink)}
.linkbtn{background:none;border:0;color:var(--accent);cursor:pointer;padding:0;font-size:13px}
</style></head><body>
<main class="in">
  <div class="head"><h1 id="pname">Dashboard</h1><span class="tag a" id="plevel"></span><span id="pwho" style="color:var(--soft);font-size:13px"></span></div>

  <section class="panel needs" aria-labelledby="h-needs">
    <h2 id="h-needs">Needs you <span class="count" id="needsN" hidden></span><small>what waits on your answer</small></h2>
    <div id="needs"><p class="wait">Loading…</p></div>
  </section>

  <div class="sdt" id="sdt">
    <a class="tile" href="/topics/Spec" target="_top"><span class="k">Spec</span><span class="n" id="tS">…</span><span class="s" id="tSs">reading the Spec</span></a>
    <a class="tile" href="/topics/Design" target="_top"><span class="k">Design</span><span class="n" id="tD">…</span><span class="s" id="tDs">reading the Design</span></a>
    <a class="tile" href="/topics/Testing" target="_top"><span class="k">Test</span><span class="n" id="tT">…</span><span class="seg" id="tTseg"></span><span class="s" id="tTs">reading the tests</span></a>
  </div>

  <div class="cols">
    <section class="panel c7" aria-labelledby="h-feat">
      <h2 id="h-feat">Features <small id="featN"></small><a class="more" href="/features" target="_top">The feature list</a></h2>
      <input class="search" id="fq" type="search" placeholder="Search features and behaviours: a name, a code, a word" aria-label="Search features and behaviours">
      <div id="feats"><p class="wait">Loading…</p></div>
      <div class="key"><span><i style="background:var(--leaf)"></i>has a test that is built</span><span><i style="background:var(--rose)"></i>failing</span><span><i style="background:var(--sun)"></i>test planned</span><span><i style="background:var(--line)"></i>no test</span></div>
    </section>
    <div class="c5" style="display:grid;gap:18px;align-content:start">
      <section class="panel" aria-labelledby="h-tests">
        <h2 id="h-tests">Tests <small id="testsN"></small><a class="more" href="/topics/Testing" target="_top">Testing</a></h2>
        <div id="tests"><p class="wait">Loading…</p></div>
      </section>
      <section class="panel" aria-labelledby="h-gaps">
        <h2 id="h-gaps">Gaps <small>where spec, design and test disagree</small></h2>
        <div id="gaps"><p class="wait">Loading…</p></div>
      </section>
    </div>

    <section class="panel c12" aria-labelledby="h-pipe">
      <h2 id="h-pipe">Work in motion <small>the pipeline, simplified: a ticket moves right as it is worked</small><a class="more" href="/pipeline" target="_top">Open the pipeline</a></h2>
      <div id="pipe"><p class="wait">Loading…</p></div>
    </section>

    <section class="panel c12" aria-labelledby="h-apps">
      <h2 id="h-apps">Apps <small>what people use</small><a class="more" href="/topics/Toolbox" target="_top">Toolbox</a></h2>
      <div class="apps" id="apps"><p class="wait">Loading…</p></div>
    </section>

    <section class="panel c7" aria-labelledby="h-chg">
      <h2 id="h-chg">What changed <small>decisions and changes, newest first</small><a class="more" href="/history" target="_top">History</a></h2>
      <div id="chg"><p class="wait">Loading…</p></div>
    </section>
    <section class="panel c5" aria-labelledby="h-ai">
      <h2 id="h-ai">Your AI <small>who is up now</small><a class="more" href="/agents" target="_top">Agents</a></h2>
      <div id="ai"><p class="wait">Loading…</p></div>
    </section>

    <section class="panel c12" aria-labelledby="h-th">
      <h2 id="h-th">A thought <small>note it here; your designer picks it up with the next streak</small></h2>
      <div class="think">
        <form id="tf"><textarea id="tx" aria-label="A thought" placeholder="#bug … or #idea …  Name a thing of this project (a class, a feature) and the map on the right goes to it."></textarea>
        <div class="pop" id="sug" role="listbox" aria-label="Completions" hidden></div>
        <div style="display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap"><button class="btn primary" type="submit">Note it</button><span class="said" id="said" role="status"></span></div></form>
        <div class="brain"><canvas id="brain" role="img" aria-label="A map of this project's classes and features"></canvas><p class="bcap" id="bcap">Loading the map…</p></div>
      </div>
    </section>
  </div>
  <p class="mock">A first try, built as the topic <code>App:Dashboard</code>. Spec, Design, Test, Features, Tests, Gaps and Apps are worked out from this project's own topics each time the page opens. Failing tests show once an implementation has its test report (<code>Report:testingProto1</code>). Not here yet: Prototypes, Data.</p>
</main>
<script>
// Reads a project's Spec, SpecUi, Design and Testing topics into features, behaviours, tests and gaps.
function d2parse(T){
  const CODE=/\^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*-\d+[a-z]?)/g, HEAD=/^(#{2,4})\s+(.*?)\s*(?:\{#([\w-]+)\})?\s*$/;
  const clean=s=>s.replace(/\[\[[^\]|]*\|([^\]]*)\]\]/g,"$1").replace(/\[\[([^\]]*)\]\]/g,"$1").replace(/\[([^\]]*)\]\([^)]*\)/g,"$1").replace(/[*`]/g,"").replace(/\s+/g," ").trim();
  const title=s=>clean(s).replace(/^\d+(\.\d+)*\.?\s+/,"").replace(/\s*\((Razie|decided|design tests)[^)]*\)\s*/gi," ").trim();
  const feats=new Map(), beh=new Map();
  for(const topic of ["Spec","SpecUi"]){
    let sec={title:topic,id:""};
    for(const line of (T[topic]||"").split("\n")){
      const h=HEAD.exec(line); if(h){sec={title:title(h[2]),id:h[3]||""};continue}
      const m=/\^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*-\d+[a-z]?)\s*$/.exec(line); if(!m||!/^\s*[-*]\s/.test(line))continue;
      const code=m[1], pre=code.replace(/-\d+[a-z]?$/,"");
      if(beh.has(code))continue;
      const b={code,pre,text:clean(line.replace(/^\s*[-*]\s+/,"").replace(/\s*\^[\w-]+\s*$/,"")),topic,sec:sec.id,tests:[]};
      beh.set(code,b);
      if(!feats.has(pre))feats.set(pre,{id:pre,name:sec.title,secs:new Set(),beh:[],designed:false});
      const f=feats.get(pre);f.beh.push(b);if(sec.id)f.secs.add(sec.id);
    }
  }
  // design: a feature is designed when Design names one of its codes, or has a section with one of its ids
  const D=T.Design||"", dIds=new Set(), dPre=new Set(), dSecs=[];
  for(const line of D.split("\n")){const h=HEAD.exec(line);if(h){if(h[3]){dIds.add(h[3]);dSecs.push({id:h[3],title:title(h[2]),refs:0})}continue}
    let m;CODE.lastIndex=0;while((m=CODE.exec(line))){dPre.add(m[1].replace(/-\d+[a-z]?$/,""));if(dSecs.length)dSecs[dSecs.length-1].refs++}
    if(dSecs.length&&/\[\[Spec(Ui)?#/.test(line))dSecs[dSecs.length-1].refs++}
  for(const f of feats.values())f.designed=dPre.has(f.id)||[...f.secs].some(s=>dIds.has(s));
  // tests: a line of Testing that starts with a test id; Tested / Planned as the line or its heading says
  const tests=[];let planned=false;
  for(const line of (T.Testing||"").split("\n")){
    const h=HEAD.exec(line);if(h){planned=/planned/i.test(h[2])&&!/all built|built:/i.test(h[2]);continue}
    const m=/^\s*[-*]\s+\**([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+[a-z]?)\b/.exec(line);if(!m)continue;
    const id=m[1], st=/\*Tested|Tested\.\*|\bTested\b\.?\s*(\[\[|$)/.test(line)?"tested":/\*Planned|Planned\.\*|\btodo\b/i.test(line)?"planned":planned?"planned":"tested";
    const t={id,state:st,text:clean(line.replace(/^\s*[-*]\s+/,"")).slice(0,160),codes:new Set()};
    let c;CODE.lastIndex=0;while((c=CODE.exec(line)))t.codes.add(c[1]);
    if(!t.codes.size)t.codes.add(id.toLowerCase());
    tests.push(t);for(const k of t.codes){const b=beh.get(k);if(b)b.tests.push(t)}
  }
  // the test report: a line a test, pass | FAIL | skip, and after " :: " why
  const rep={has:false,top:"",res:new Map(),fails:[]};
  for(const line of (T.Report||"").split("\n")){
    if(/^Last full run:/.test(line))rep.top=line.trim();
    const m=/^\s*-\s+(pass|FAIL|skip)\s+([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+[a-z]?)\s+(.*?)(?:\s+::\s+(.*))?$/.exec(line);if(!m)continue;
    rep.has=true;const r={res:m[1].toLowerCase(),id:m[2],name:m[3],why:m[4]||""};rep.res.set(r.id,r);if(r.res==="fail")rep.fails.push(r)}
  for(const t of tests){const r=rep.res.get(t.id);t.run=r?r.res:""}
  const state=b=>!b.tests.length?"none":b.tests.some(t=>t.run==="fail")?"failing":b.tests.some(t=>t.state==="tested")?"tested":"planned";
  const F=[...feats.values()].map(f=>{const n={tested:0,planned:0,none:0,failing:0};for(const b of f.beh){b.state=state(b);n[b.state]++}return{...f,secs:[...f.secs],n}}).sort((a,b)=>b.beh.length-a.beh.length);
  const all=[...beh.values()], sum={features:F.length,behaviours:all.length,designed:F.filter(f=>f.designed).length,
    tests:tests.length,tTested:tests.filter(t=>t.state==="tested").length,tPlanned:tests.filter(t=>t.state==="planned").length,
    bTested:all.filter(b=>b.state==="tested").length,bPlanned:all.filter(b=>b.state==="planned").length,bNone:all.filter(b=>b.state==="none").length,bFailing:all.filter(b=>b.state==="failing").length,rPass:[...rep.res.values()].filter(r=>r.res==="pass").length,rFail:rep.fails.length};
  const fIds=new Set(F.flatMap(f=>f.secs));
  const gaps={noDesign:F.filter(f=>!f.designed),noTest:F.filter(f=>f.n.none).sort((a,b)=>b.n.none-a.n.none),orphanTests:tests.filter(t=>![...t.codes].some(k=>beh.has(k))).length,
    designOnly:dSecs.filter(s=>!s.refs&&!fIds.has(s.id)&&!/changelog|principles|system|goals/i.test(s.id))};
  return{F,tests,sum,gaps,rep};
}

const WAITS="P-1039";
const $=id=>document.getElementById(id), esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
const api=(m,p,b)=>window.d2.api(m,p,b);
const waitLine=(what,e)=>'<p class="wait">'+(/E_SCOPE/.test(String(e&&e.message||e))?'Shows once an app may read '+what+' ('+WAITS+', with the coders).':'Could not read '+what+': '+esc(String(e&&e.message||e).slice(0,140)))+'</p>';
const ago=t=>{const s=(Date.now()-Date.parse(t))/1000;return s<90?"now":s<3600?Math.round(s/60)+" min":s<86400?Math.round(s/3600)+" h":Math.round(s/86400)+" d"};
const hl=(s,q)=>{if(!q)return esc(s);const i=s.toLowerCase().indexOf(q);return i<0?esc(s):esc(s.slice(0,i))+"<mark>"+esc(s.slice(i,i+q.length))+"</mark>"+esc(s.slice(i+q.length))};
let R=null,me=null,open=new Set(),showAll=false;

async function who(){try{me=await api("GET","/api/v2/me");const h=me.here||{};$("pname").textContent=h.project||me.project||"Dashboard";$("plevel").textContent=h.level||"";document.title=(h.project||"")+" · Dashboard"}catch(e){}}

async function spec(){
  const T={};await Promise.all([["Spec","Spec"],["SpecUi","SpecUi"],["Design","Design"],["Testing","Testing"]].map(async([k,n])=>{try{const r=await api("GET","/api/v2/topics/"+n);T[k]=typeof r==="string"?r:(r&&r.text)||""}catch(e){T[k]=""}}));
  let reps=[];try{reps=((await api("GET","/api/v2/topics?category=Report")).data||[]).map(t=>t.name).filter(n=>/^Report:testing/.test(n)).sort()}catch(e){}
  const RT=[];for(const n of reps){try{const r=await api("GET","/api/v2/topics/"+n);if(typeof r==="string")RT.push([n,r])}catch(e){}}
  T.Report=RT.map(x=>x[1]).join("\n");window.REPS=RT.map(x=>x[0]);
  if(!T.Testing){try{const r=await api("GET","/api/v2/topics/SpecTest");T.Testing=typeof r==="string"?r:"";if(T.Testing)document.querySelectorAll('a[href="/topics/Testing"]').forEach(a=>a.href="/topics/SpecTest")}catch(e){}}
  R=d2parse(T);const s=R.sum;
  $("tS").innerHTML=s.features+' <small>features</small>';$("tSs").textContent=s.behaviours+" behaviours"+(T.SpecUi?", in Spec and SpecUi":"");
  $("tD").innerHTML=s.designed+' <small>of '+s.features+' designed</small>';$("tDs").textContent=(s.features-s.designed)?(s.features-s.designed)+" features have behaviours and no design yet":"every feature has a design";
  const tot=s.behaviours||1;$("tT").innerHTML=s.bTested+' <small>of '+s.behaviours+' behaviours tested</small>';
  $("tTseg").innerHTML='<i class="p" style="width:'+(s.bTested/tot*100).toFixed(1)+'%"></i><i class="f" style="width:'+(s.bFailing/tot*100).toFixed(1)+'%"></i><i class="n0" style="width:'+(s.bPlanned/tot*100).toFixed(1)+'%"></i>';
  $("tTs").textContent=(R.rep.has?s.bFailing+" failing, ":"")+s.bPlanned+" with a test planned, "+s.bNone+" with no test";
  $("featN").textContent=s.features+", with their behaviours";
  $("testsN").textContent=s.tests+" test lines";
  drawFeats();drawTests();drawGaps();
}
function drawFeats(){
  if(!R)return;const q=$("fq").value.trim().toLowerCase(),out=[];let n=0;
  for(const f of R.F){
    const hitF=!q||f.name.toLowerCase().includes(q)||f.id.includes(q),hitB=q?f.beh.filter(b=>b.code.includes(q)||b.text.toLowerCase().includes(q)):[];
    if(!hitF&&!hitB.length)continue;if(!q&&!showAll&&n>=12){n++;continue}n++;
    const tot=f.beh.length,w=k=>(f.n[k]/tot*100).toFixed(1)+"%",list=q&&hitB.length?hitB:open.has(f.id)?f.beh:[];
    out.push('<div class="feat" data-f="'+esc(f.id)+'"><span class="nm">'+hl(f.name,q)+'<code>'+hl(f.id,q)+'</code>'+(f.designed?'':'<span class="st none">no design</span>')+'</span><span class="num">'+tot+(tot===1?' behaviour':' behaviours')+'</span><span class="seg" role="img" aria-label="'+f.n.tested+' tested, '+f.n.failing+' failing, '+f.n.planned+' planned, '+f.n.none+' with no test"><i class="p" style="width:'+w("tested")+'"></i><i class="f" style="width:'+w("failing")+'"></i><i class="n0" style="width:'+w("planned")+'"></i><i class="n1" style="width:'+w("none")+'"></i></span>'
      +(list.length?'<span class="beh">'+list.slice(0,40).map(b=>'<span><code>^'+hl(b.code,q)+'</code> '+hl(b.text.length>170?b.text.slice(0,170)+"…":b.text,q)+'<span class="st '+b.state+'">'+(b.state==="none"?"no test":b.state)+'</span></span>').join("")+'</span>':'')+'</div>');
  }
  $("feats").innerHTML=(out.join("")||'<p class="none">No feature or behaviour matches. Try a code like pipe-3, or a word.</p>')+(!q&&!showAll&&n>12?'<p class="none"><button class="linkbtn" id="allf" type="button">Show all '+n+' features</button></p>':'');
}
$("fq").addEventListener("input",drawFeats);
$("feats").addEventListener("click",e=>{if(e.target.id==="allf"){showAll=true;return drawFeats()}const f=e.target.closest(".feat");if(!f)return;const id=f.dataset.f;open.has(id)?open.delete(id):open.add(id);drawFeats()});
function drawTests(){
  const s=R.sum,worst=R.gaps.noTest.slice(0,5);
  $("tests").innerHTML='<ul class="rows"><li><span class="tag ok">Built</span><span class="t">'+s.tTested+' tests are written and built</span></li><li><span class="tag d">Planned</span><span class="t">'+s.tPlanned+' tests are written and not built yet</span></li><li><span class="tag">No test</span><span class="t">'+s.bNone+' behaviours have no test line'+(worst.length?': '+worst.map(f=>esc(f.name)+" ("+f.n.none+")").join(", "):'')+'</span></li>'+(R.gaps.orphanTests?'<li><span class="tag">Loose</span><span class="t">'+R.gaps.orphanTests+' tests point at no behaviour in the Spec</span></li>':'')+(R.rep.has?'':'<li><span class="tag">Runs</span><span class="t">Passing and failing are not shown: no implementation has a test report yet (P-1040)</span></li>')+'</ul>';
  if(R.rep.has)$("tests").insertAdjacentHTML("afterbegin",'<p class="none" style="padding:0">'+esc(R.rep.top||"")+' '+(window.REPS||[]).map(n=>'<a target="_top" href="/topics/'+n+'">'+esc(n.replace("Report:testing",""))+' report</a>').join(" · ")+'</p><ul class="rows">'+(R.rep.fails.length?R.rep.fails.slice(0,6).map(r=>'<li><span class="tag bad">Fails</span><span class="t"><a target="_top" href="/topics/'+((window.REPS||[])[0]||"")+'#f-'+esc(r.id.toLowerCase())+'"><code>'+esc(r.id)+'</code></a> '+esc(r.name.slice(0,110))+(r.why?'<br><span class="w" style="white-space:normal">'+esc(r.why.slice(0,200))+'</span>':'')+'</span></li>').join("")+(R.rep.fails.length>6?'<li><span class="t">and '+(R.rep.fails.length-6)+' more failing</span></li>':''):'<li><span class="tag ok">Passing</span><span class="t">Every test in the last run passed ('+s.rPass+')</span></li>')+'</ul>');
}
function drawGaps(){
  const g=R.gaps,li=[];
  if(g.noDesign.length)li.push('<li><span class="t"><b>'+g.noDesign.length+' features</b> have behaviours and no design: '+g.noDesign.slice(0,6).map(f=>esc(f.name)).join(", ")+(g.noDesign.length>6?"…":"")+'</span></li>');
  if(g.designOnly.length)li.push('<li><span class="t"><b>'+g.designOnly.length+' design sections</b> name no behaviour: '+g.designOnly.slice(0,5).map(s=>esc(s.title)).join(", ")+(g.designOnly.length>5?"…":"")+'</span></li>');
  const un=R.F.filter(f=>f.n.tested===0&&f.beh.length>1);if(un.length)li.push('<li><span class="t"><b>'+un.length+' features</b> have no built test at all: '+un.slice(0,6).map(f=>esc(f.name)).join(", ")+(un.length>6?"…":"")+'</span></li>');
  $("gaps").innerHTML=li.length?'<ul class="rows">'+li.join("")+'</ul>':'<p class="none">Spec, design and tests agree.</p>';
}
async function apps(){try{const r=await api("GET","/api/v2/topics?category=App");const a=(r.data||[]).filter(t=>t.name!=="App:Dashboard");
  $("apps").innerHTML=a.length?a.map(t=>{const n=t.name.replace(/^App:/,"");return '<a class="app" target="_top" href="/app/'+encodeURIComponent(n)+'"><b>'+esc(n)+'</b><span>changed '+ago(t.updated)+' ago</span></a>'}).join(""):'<p class="none">No apps yet.</p>'}catch(e){$("apps").innerHTML=waitLine("the apps",e)}}

const LANES=[["New",["new","pending"]],["Working",["in-progress","review"]],["Waiting",["waiting","waiting-error"]],["Waiting on a person",["waiting-input"]],["Done",["done"]]];
let last=new Map();
async function pipe(first){
  try{const r=await api("GET","/api/v2/pipeline?status=open"),items=r.data||[];let done=[];
    try{done=((await api("GET","/api/v2/pipeline?status=done&limit=4")).data||[]).slice(0,4)}catch(e){}
    const all=items.concat(done),moved=new Set();for(const i of all){if(!first&&last.has(i.id)&&last.get(i.id)!==i.status)moved.add(i.id)}last=new Map(all.map(i=>[i.id,i.status]));
    $("pipe").innerHTML='<div class="lanes">'+LANES.map(([n,st],k)=>{const ts=all.filter(i=>st.includes(i.status)).sort((a,b)=>String(b.updated).localeCompare(String(a.updated)));
      return '<div class="lane"><h3><span>'+n+'</span><span>'+ts.length+'</span></h3>'+ts.slice(0,4).map(t=>'<a class="tk'+(k===3?" you":k===4?" done":"")+'" target="_top" href="/pipeline/'+t.id+'" style="color:inherit;text-decoration:none'+(moved.has(t.id)?'':';animation:none')+'"><code>'+t.id+(t.forRole?" · "+esc(t.forRole):"")+'</code>'+esc(String(t.title||"").slice(0,70))+'</a>').join("")+(ts.length>4?'<span class="w" style="font-size:12px;color:var(--soft)">and '+(ts.length-4)+' more</span>':'')+'</div>'}).join("")+'</div>';
    const mine=items.filter(i=>i.status==="waiting-input"&&me&&[i.waitingOn,i.forName].includes(me.handle)).sort((a,b)=>String(a.updated).localeCompare(String(b.updated)));
    needs(mine);
  }catch(e){$("pipe").innerHTML=waitLine("the pipeline",e);$("needs").innerHTML=waitLine("the pipeline and your drafts",e)}
}
async function needs(mine){
  let dr=0;try{dr=((await api("GET","/api/v2/drafts")).data||[]).length}catch(e){}
  const n=mine.length+(dr?1:0);$("needsN").hidden=!n;$("needsN").textContent=n;
  $("needs").innerHTML=n?'<ul class="rows">'+(dr?'<li><span class="tag d">Drafts</span><span class="t">'+dr+(dr===1?' draft':' drafts')+' to read and publish</span><a class="btn" target="_top" href="/drafts">Open</a></li>':'')+mine.slice(0,6).map(i=>'<li><span class="tag q">Question</span><span class="t">'+esc(i.title)+' <a target="_top" href="/pipeline/'+i.id+'">'+i.id+'</a></span><span class="w">'+ago(i.updated)+'</span><a class="btn primary" target="_top" href="/pipeline/'+i.id+'">Answer</a></li>').join("")+(mine.length>6?'<li><span class="t"><a target="_top" href="/pipeline">and '+(mine.length-6)+' more</a></span></li>':'')+'</ul>':'<p class="none">Nothing waits on you.</p>';
}
async function changed(){try{const r=await api("GET","/api/v2/history?limit=60"),d=(r.data||[]).filter(h=>h.type==="decision"||h.type==="change").slice(0,7);
  $("chg").innerHTML=d.length?'<ul class="rows">'+d.map(h=>'<li><span class="tag '+(h.type==="decision"?"a":"")+'">'+(h.type==="decision"?"Decided":"Changed")+'</span><span class="t">'+esc(String(h.text||"").split("\n")[0].slice(0,120))+(h.item?' <a target="_top" href="/pipeline/'+esc(h.item)+'">'+esc(h.item)+'</a>':'')+'</span><span class="w">'+ago(h.at)+'</span></li>').join("")+'</ul>':'<p class="none">No decisions or changes recorded yet.</p>'}catch(e){$("chg").innerHTML=waitLine("the history",e)}}
async function ai(){try{const r=await api("GET","/api/v2/agents"),a=(r.agents||[]).filter(x=>!["gone","done","stopped"].includes(x.state)&&Date.now()-Date.parse(x.at)<Math.max(3600,(x.every||0)*2)*1000).slice(0,6);
  $("ai").innerHTML=a.length?'<ul class="rows">'+a.map(x=>'<li><span class="t"><span class="dot'+(x.state==="working"?"":" idle")+'"></span><b>'+esc(x.agent)+'</b> '+esc(String(x.text||x.state).slice(0,90))+'</span><span class="w">'+ago(x.at)+'</span></li>').join("")+'</ul>':'<p class="none">No agent is up right now.</p>'}catch(e){$("ai").innerHTML=waitLine("the agents",e)}}

// ---- the map beside the thought box: the project's classes and their links, and its features; typing a known word goes there
async function domain(){
  const C=[];let names=[];
  try{names=((await api("GET","/api/v2/topics?category=Spec")).data||[]).map(t=>t.name).slice(0,12)}catch(e){}
  for(const n of names){let t="";try{const r=await api("GET","/api/v2/topics/"+n);t=typeof r==="string"?r:""}catch(e){}
    const re=/\$class\s+(\w+)\s*\(([\s\S]*?)\n\)([^\n]*)/g;let m;
    while((m=re.exec(t))){const fields=[];for(const l of m[2].split("\n")){const f=/^\s*(?:@\w+\s+)*(\w+)\s*:\s*(?:<>)?\s*(\w+)\s*([*?]?)/.exec(l);if(f)fields.push({name:f[1],type:f[2],many:f[3]==="*"})}
      C.push({name:m[1],fields,group:(/@group\("([^"]*)"\)/.exec(m[3])||[])[1]||"",tip:(/@tooltip\("([^"]*)"\)/.exec(m[3])||[])[1]||""})}}
  return C;
}
async function brain(){
  const cv=$("brain"),cap=$("bcap"),g=cv.getContext("2d"),still=matchMedia("(prefers-reduced-motion: reduce)").matches;
  const C=await domain(),byName=new Map(C.map(c=>[c.name,c])),N=[],E=[];
  for(const c of C)N.push({id:"c:"+c.name,kind:"class",label:c.name,group:c.group,tip:c.tip,words:[c.name.toLowerCase(),c.name.toLowerCase()+"s",c.name.replace(/([a-z])([A-Z])/g,"$1 $2").toLowerCase()],r:7});
  const node=new Map(N.map(n=>[n.id,n]));
  for(const c of C)for(const f of c.fields)if(byName.has(f.type)&&f.type!==c.name)E.push({a:node.get("c:"+c.name),b:node.get("c:"+f.type),label:f.name+(f.many?" (many)":"")});
  const F=(R&&R.F||[]).slice(0,36);
  for(const f of F){const n={id:"f:"+f.id,kind:"feature",label:f.name,sub:f.beh.length+" behaviours",words:[f.name.toLowerCase(),f.id],r:4,f};N.push(n);
    const text=f.beh.map(b=>b.text).join(" ").toLowerCase();
    for(const c of C){const k=text.split(c.name.replace(/([a-z])([A-Z])/g,"$1 $2").toLowerCase()).length-1;if(k>=2)E.push({a:n,b:node.get("c:"+c.name),soft:true,label:"mentions"})}}
  if(!N.length){cap.textContent="No classes or features to draw yet: the map fills in from the project's Spec.";return}
  // layout: classes on an inner ring by group, features on an outer ring, then a few rounds of springs
  const cls=N.filter(n=>n.kind==="class").sort((x,y)=>x.group.localeCompare(y.group)),fe=N.filter(n=>n.kind==="feature");
  cls.forEach((n,i)=>{const t=i/Math.max(1,cls.length)*6.283;n.x=Math.cos(t)*150;n.y=Math.sin(t)*110});
  fe.forEach((n,i)=>{const t=i/Math.max(1,fe.length)*6.283+.3;n.x=Math.cos(t)*(cls.length?330:190);n.y=Math.sin(t)*(cls.length?240:140)});
  for(let k=0;k<160;k++){for(const a of N)for(const b of N){if(a===b)continue;const dx=a.x-b.x,dy=a.y-b.y,d2=dx*dx+dy*dy+1,f=2600/d2;a.x+=dx/Math.sqrt(d2)*f;a.y+=dy/Math.sqrt(d2)*f}
    for(const e of E){const dx=e.b.x-e.a.x,dy=e.b.y-e.a.y,d=Math.sqrt(dx*dx+dy*dy)+1,f=(d-(e.soft?170:125))*.02;e.a.x+=dx/d*f;e.a.y+=dy/d*f;e.b.x-=dx/d*f;e.b.y-=dy/d*f}}
  const nb=n=>E.filter(e=>e.a===n||e.b===n).map(e=>({n:e.a===n?e.b:e.a,e}));
  let W=0,H=0,dpr=1,cam={x:0,y:0,z:1},to={x:0,y:0,z:1},focus=null,pulses=[],t0=0;
  const col=k=>getComputedStyle(document.documentElement).getPropertyValue(k).trim();
  function size(){dpr=Math.min(2,devicePixelRatio||1);W=cv.clientWidth;H=cv.clientHeight;cv.width=W*dpr;cv.height=H*dpr}
  function fit(){let x0=1e9,x1=-1e9,y0=1e9,y1=-1e9;for(const n of N){x0=Math.min(x0,n.x);x1=Math.max(x1,n.x);y0=Math.min(y0,n.y);y1=Math.max(y1,n.y)}
    to={x:(x0+x1)/2,y:(y0+y1)/2,z:Math.min(W/(x1-x0+120),H/(y1-y0+70))}}
  function go(n){focus=n;if(!n){fit();cap.innerHTML=C.length?"<b>"+C.length+" classes</b> and their links"+(F.length?", with the "+F.length+" largest features around them":"")+". Type a name on the left, or tap a dot.":"<b>"+F.length+" features</b>. This project has no class model yet, so the map shows its features only.";return}
    const ns=nb(n);let x0=n.x,x1=n.x,y0=n.y,y1=n.y;for(const {n:m} of ns){x0=Math.min(x0,m.x);x1=Math.max(x1,m.x);y0=Math.min(y0,m.y);y1=Math.max(y1,m.y)}
    to={x:(x0+x1)/2,y:(y0+y1)/2,z:Math.min(2.2,W/(x1-x0+220),H/(y1-y0+120))};pulses=ns.slice(0,12).map(({n:m},i)=>({a:n,b:m,p:-i*.12}));
    const links=ns.filter(x=>!x.e.soft).map(x=>esc(x.e.a===n?x.e.label+" → "+x.n.label:x.n.label+"."+x.e.label)),near=ns.filter(x=>x.e.soft).map(x=>esc(x.n.label));
    cap.innerHTML="<b>"+esc(n.label)+"</b> "+(n.kind==="class"?"class"+(n.group?" in "+esc(n.group):""):"feature, "+esc(n.sub))+(n.tip?": "+esc(n.tip):"")+(links.length?"<br>"+links.slice(0,6).join(" · "):"")+(near.length?"<br>"+(n.kind==="class"?"Features that mention it: ":"Mentions: ")+near.slice(0,6).join(", "):"")}
  function draw(ts){
    const dt=Math.min(.05,(ts-t0)/1000||0);t0=ts;const k=still?1:Math.min(1,dt*4);cam.x+=(to.x-cam.x)*k;cam.y+=(to.y-cam.y)*k;cam.z+=(to.z-cam.z)*k;
    const ink=col("--ink"),soft=col("--soft"),line=col("--line"),acc=col("--accent"),attn=col("--attn"),leaf=col("--leaf");
    g.setTransform(dpr,0,0,dpr,0,0);g.clearRect(0,0,W,H);g.translate(W/2,H/2);g.scale(cam.z,cam.z);g.translate(-cam.x,-cam.y);
    const hot=new Set(focus?[focus,...nb(focus).map(x=>x.n)]:[]);
    for(const e of E){const on=focus&&(e.a===focus||e.b===focus);g.beginPath();g.moveTo(e.a.x,e.a.y);g.lineTo(e.b.x,e.b.y);g.strokeStyle=on?acc:line;g.globalAlpha=on?.9:focus?.25:e.soft?.45:.9;g.lineWidth=(on?1.6:1)/cam.z;g.setLineDash(e.soft?[4/cam.z,4/cam.z]:[]);g.stroke()}
    g.setLineDash([]);g.globalAlpha=1;
    if(!still)for(const p of pulses){p.p+=dt*.9;if(p.p>1)p.p-=1.25;if(p.p<0)continue;g.beginPath();g.arc(p.a.x+(p.b.x-p.a.x)*p.p,p.a.y+(p.b.y-p.a.y)*p.p,3/cam.z,0,6.3);g.fillStyle=attn;g.fill()}
    for(const n of N){const on=hot.has(n),r=(n===focus?n.r+3:n.r);g.globalAlpha=focus&&!on?.3:1;g.beginPath();g.arc(n.x,n.y,r,0,6.3);g.fillStyle=n===focus?acc:n.kind==="class"?ink:leaf;g.fill();
      if(focus?on:(n.kind==="class"||cam.z>1.5)){g.font=(n===focus?"700 ":n.kind==="class"?"600 ":"400 ")+(12/Math.max(.75,cam.z)*(n===focus?1.15:1))+"px system-ui,sans-serif";g.fillStyle=n===focus?acc:n.kind==="class"?ink:soft;g.textAlign="center";g.fillText(n.label.length>26?n.label.slice(0,25)+"…":n.label,n.x,n.y-r-5/cam.z)}}
    g.globalAlpha=1;requestAnimationFrame(draw)}
  // What the map knows of what is being typed. Every node has keys: its names, and the longer words of its label.
  // The word under the caret matches by its beginning, so the map stays put while a word is being finished
  // ("pipe" → "pipel" → "pipeline"); words already finished match whole.
  const STOP=new Set(["the","and","for","with","from","that","this","page","pages","your","their","into","over","when","what","each","every","more"]);
  for(const n of N){const ks=new Set(n.words);for(const w of n.label.toLowerCase().split(/[^a-z0-9]+/))if(w.length>=4&&!STOP.has(w))ks.add(w);n.keys=[...ks].filter(k=>k.length>=3)}
  // Completion: what the word under the caret could become, best first. A thing linked to something already
  // named in the text comes before one that isn't; Tab takes the first, Tab again the next.
  let sug=[],si=0,part0="",moved=false;
  const said=t=>{const s=new Set();for(const n of N)for(const k of n.keys){const i=t.indexOf(k);if(i>=0&&!/[a-z0-9]/.test(t[i-1]||" ")&&!/[a-z0-9]/.test(t[i+k.length]||" ")){s.add(n);break}}return s};
  const wordFor=(n,part)=>n.kind==="class"?n.label:(n.label.toLowerCase().split(/[^a-z0-9]+/).find(w=>w.startsWith(part)&&w.length>=4)||n.keys.find(k=>k.startsWith(part))||n.label.toLowerCase());
  // where the caret is inside the textarea, by laying the same text out in a hidden copy
  function caretXY(x,pos){const m=document.createElement("div"),cs=getComputedStyle(x);
    for(const p of ["boxSizing","width","fontFamily","fontSize","fontWeight","lineHeight","letterSpacing","paddingTop","paddingRight","paddingBottom","paddingLeft","borderTopWidth","borderLeftWidth","borderRightWidth","tabSize"])m.style[p]=cs[p];
    m.style.cssText+=";position:absolute;visibility:hidden;white-space:pre-wrap;word-wrap:break-word;overflow:hidden;top:0;left:0";
    m.textContent=x.value.slice(0,pos);const s=document.createElement("span");s.textContent="​";m.appendChild(s);x.parentNode.appendChild(m);
    const r={x:s.offsetLeft,y:s.offsetTop-x.scrollTop,h:parseFloat(cs.lineHeight)||s.offsetHeight||18};m.remove();return r}
  function draw_sug(){const p=$("sug"),x=$("tx");if(!sug.length){p.hidden=true;p.innerHTML="";return}
    p.innerHTML=sug.map((s,k)=>{const t=s.text,l=part0.length;return '<div class="o'+(k===si?' on':'')+'" role="option" aria-selected="'+(k===si)+'" data-k="'+k+'"><b><u>'+esc(t.slice(0,l))+'</u>'+esc(t.slice(l))+'</b><span class="k">'+s.n.kind+'</span><span class="d">'+esc(s.n.kind==="class"?(s.n.tip||s.n.group||""):s.n.label+" · "+s.n.sub)+'</span></div>'}).join("")+'<div class="f"><kbd>Tab</kbd> takes it · <kbd>↑</kbd><kbd>↓</kbd> choose · <kbd>Esc</kbd> closes</div>';
    p.hidden=false;const c=caretXY(x,x.selectionStart),fw=x.parentNode.clientWidth;
    p.style.left=Math.max(0,Math.min(x.offsetLeft+c.x,fw-p.offsetWidth))+"px";
    const below=x.offsetTop+c.y+c.h+4;p.style.top=(below+p.offsetHeight>x.offsetTop+x.offsetHeight+90?Math.max(0,x.offsetTop+c.y-p.offsetHeight-4):below)+"px"}
  function read(){const x=$("tx"),pos=x.selectionStart==null?x.value.length:x.selectionStart,t=x.value.slice(0,pos).toLowerCase();
    const part=(/[a-z0-9-]+$/.exec(t)||[""])[0];
    sug=[];si=0;moved=false;part0=part;
    if(part.length>=2){const before=said(t.slice(0,t.length-part.length)),near=new Set();for(const m of before)for(const {n} of nb(m))near.add(n);
      const best=new Map();
      for(const n of N)for(const k of n.keys){if(!k.startsWith(part))continue;const s=(k===part?1000:0)+(near.has(n)?200:0)+(n.kind==="class"?100:0)+(n===focus?50:0)-k.length;if(!best.has(n)||best.get(n)<s)best.set(n,s)}
      const seen=new Set(),c=[...best.entries()].sort((p,q)=>q[1]-p[1]).map(([n])=>({n,text:wordFor(n,part)})).filter(s=>{const k=s.text.toLowerCase();if(seen.has(k))return false;seen.add(k);return true}).slice(0,6);
      sug=c.filter(s=>s.text.toLowerCase()!==part);draw_sug();
      if(part.length>=3&&c.length){if(c[0].n!==focus)go(c[0].n)}
      return}
    draw_sug();
    let best=null,at=-1,len=0;
    for(const n of N)for(const k of n.keys){const i=t.lastIndexOf(k);if(i<0)continue;const b=t[i-1],a=t[i+k.length];if((b&&/[a-z0-9]/.test(b))||(a&&/[a-z0-9]/.test(a)))continue;
      if(i>at||(i===at&&k.length>len)){at=i;len=k.length;best=n}}
    if(best!==focus)go(best)}
  function complete(k){const x=$("tx"),s=sug[k];if(!s)return;const end=x.selectionStart,start=end-part0.length;
    x.setRangeText(s.text+" ",start,end,"end");go(s.n);sug=[];draw_sug();x.focus()}
  $("tx").addEventListener("keydown",e=>{if(!sug.length||e.altKey||e.ctrlKey||e.metaKey)return;
    if(e.key==="ArrowDown"||e.key==="ArrowUp"){e.preventDefault();si=(si+(e.key==="ArrowDown"?1:sug.length-1))%sug.length;moved=true;go(sug[si].n);draw_sug()}
    else if(e.key==="Tab"&&!e.shiftKey){e.preventDefault();complete(si)}
    else if(e.key==="Enter"&&moved){e.preventDefault();complete(si)}      // Enter takes it only after you chose with the arrows; else it is a new line
    else if(e.key==="Escape"){e.preventDefault();sug=[];draw_sug()}});
  $("sug").addEventListener("mousedown",e=>{const o=e.target.closest(".o");if(o){e.preventDefault();complete(+o.dataset.k)}});
  $("tx").addEventListener("blur",()=>setTimeout(()=>{sug=[];draw_sug()},150));$("tx").addEventListener("scroll",()=>{if(sug.length)draw_sug()});
  $("tx").addEventListener("input",read);$("tx").addEventListener("click",read);$("tx").addEventListener("keyup",e=>{if(["ArrowLeft","ArrowRight","Home","End"].includes(e.key))read()});
  cv.addEventListener("click",e=>{const r=cv.getBoundingClientRect(),x=(e.clientX-r.left-W/2)/cam.z+cam.x,y=(e.clientY-r.top-H/2)/cam.z+cam.y;let hit=null,d=1e9;
    for(const n of N){const q=(n.x-x)**2+(n.y-y)**2;if(q<d&&q<(18/cam.z)**2){d=q;hit=n}}go(hit===focus?null:hit)});
  new ResizeObserver(()=>{size();if(!focus)fit()}).observe(cv);size();go(null);cam={...to};requestAnimationFrame(draw);
}
$("tf").addEventListener("submit",async e=>{e.preventDefault();const x=$("tx"),t=x.value.trim();if(!t)return;
  try{await api("POST","/api/v2/thoughts",{text:t});x.value="";$("said").style.color="";$("said").textContent="Noted. It is in your thoughts."}
  catch(err){$("said").style.color="var(--soft)";$("said").textContent=/E_SCOPE/.test(err.message)?"Not sent: an app can't add a thought yet ("+WAITS+"). Your text is still here.":"Not sent: "+err.message}});
(async()=>{await who();const sp=spec();apps();pipe(true);changed();ai();setInterval(()=>pipe(false),30000);try{await sp}catch(e){}brain()})();
</script></body></html>