templates › dashboardPro · version 1 ·
category
Template
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)
makes
App:Dashboard
tags
#template #pro #inherited
Dashboard

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}

Dashboard

Needs you what waits on your answer

Loading…

Features The feature list

Loading…

has a test that is builtfailingtest plannedno test

Tests Testing

Loading…

Gaps where spec, design and test disagree

Loading…

<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>

A first try, built as the topic App:Dashboard. 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 (Report:testingProto1). Not here yet: Prototypes, Data.

const WAITS="P-1039"; const $=id=>document.getElementById(id), esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); const api=(m,p,b)=>window.d2.api(m,p,b); const waitLine=(what,e)=>'

'+(/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)))+'

'; 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))+""+esc(s.slice(i,i+q.length))+""+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+' features';$("tSs").textContent=s.behaviours+" behaviours"+(T.SpecUi?", in Spec and SpecUi":""); $("tD").innerHTML=s.designed+' of '+s.features+' designed';$("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+' of '+s.behaviours+' behaviours tested'; $("tTseg").innerHTML=''; $("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('

'+hl(f.name,q)+''+hl(f.id,q)+''+(f.designed?'':'no design')+''+tot+(tot===1?' behaviour':' behaviours')+'<i class="p" style="width:'+w("tested")+'"><i class="f" style="width:'+w("failing")+'"><i class="n0" style="width:'+w("planned")+'"><i class="n1" style="width:'+w("none")+'">' +(list.length?''+list.slice(0,40).map(b=>'^'+hl(b.code,q)+' '+hl(b.text.length>170?b.text.slice(0,170)+"…":b.text,q)+''+(b.state==="none"?"no test":b.state)+'').join("")+'':'')+''); } $("feats").innerHTML=(out.join("")||'

No feature or behaviour matches. Try a code like pipe-3, or a word.

')+(!q&&!showAll&&n>12?'

Show all '+n+' features

':''); } $("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='
  • Built'+s.tTested+' tests are written and built
  • Planned'+s.tPlanned+' tests are written and not built yet
  • No test'+s.bNone+' behaviours have no test line'+(worst.length?': '+worst.map(f=>esc(f.name)+" ("+f.n.none+")").join(", "):'')+''+(R.gaps.orphanTests?'
  • Loose'+R.gaps.orphanTests+' tests point at no behaviour in the Spec':'')+(R.rep.has?'':'
  • RunsPassing and failing are not shown: no implementation has a test report yet (P-1040)')+''; if(R.rep.has)$("tests").insertAdjacentHTML("afterbegin",'

    '+esc(R.rep.top||"")+' '+(window.REPS||[]).map(n=>''+esc(n.replace("Report:testing",""))+' report').join(" · ")+'

      '+(R.rep.fails.length?R.rep.fails.slice(0,6).map(r=>'
    • Fails<a target="_top" href="/topics/'+((window.REPS||[])[0]||"")+'#f-'+esc(r.id.toLowerCase())+'">'+esc(r.id)+' '+esc(r.name.slice(0,110))+(r.why?'
      '+esc(r.why.slice(0,200))+'':'')+'').join("")+(R.rep.fails.length>6?'
    • and '+(R.rep.fails.length-6)+' more failing':''):'
    • PassingEvery test in the last run passed ('+s.rPass+')')+''); } function drawGaps(){ const g=R.gaps,li=[]; if(g.noDesign.length)li.push('
    • '+g.noDesign.length+' features have behaviours and no design: '+g.noDesign.slice(0,6).map(f=>esc(f.name)).join(", ")+(g.noDesign.length>6?"…":"")+''); if(g.designOnly.length)li.push('
    • '+g.designOnly.length+' design sections name no behaviour: '+g.designOnly.slice(0,5).map(s=>esc(s.title)).join(", ")+(g.designOnly.length>5?"…":"")+''); const un=R.F.filter(f=>f.n.tested===0&&f.beh.length>1);if(un.length)li.push('
    • '+un.length+' features have no built test at all: '+un.slice(0,6).map(f=>esc(f.name)).join(", ")+(un.length>6?"…":"")+''); $("gaps").innerHTML=li.length?'
        '+li.join("")+'':'

        Spec, design and tests agree.

        '; } 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 ''+esc(n)+'changed '+ago(t.updated)+' ago'}).join(""):'

        No apps yet.

        '}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='

        '+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 '

        '+n+''+ts.length+''+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')+'">'+t.id+(t.forRole?" · "+esc(t.forRole):"")+''+esc(String(t.title||"").slice(0,70))+'').join("")+(ts.length>4?'and '+(ts.length-4)+' more':'')+''}).join("")+''; 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?'
          '+(dr?'
        • Drafts'+dr+(dr===1?' draft':' drafts')+' to read and publishOpen':'')+mine.slice(0,6).map(i=>'
        • Question'+esc(i.title)+' '+i.id+''+ago(i.updated)+'Answer').join("")+(mine.length>6?'
        • and '+(mine.length-6)+' more':'')+'':'

          Nothing waits on you.

          '; } 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?'
            '+d.map(h=>'
          • <span class="tag '+(h.type==="decision"?"a":"")+'">'+(h.type==="decision"?"Decided":"Changed")+''+esc(String(h.text||"").split("\n")[0].slice(0,120))+(h.item?' '+esc(h.item)+'':'')+''+ago(h.at)+'').join("")+'':'

            No decisions or changes recorded yet.

            '}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?'
              '+a.map(x=>'
            • <span class="dot'+(x.state==="working"?"":" idle")+'">'+esc(x.agent)+' '+esc(String(x.text||x.state).slice(0,90))+''+ago(x.at)+'').join("")+'':'

              No agent is up right now.

              '}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=Wdpr;cv.height=Hdpr} 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?""+C.length+" classes and their links"+(F.length?", with the "+F.length+" largest features around them":"")+". Type a name on the left, or tap a dot.":""+F.length+" features. 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=""+esc(n.label)+" "+(n.kind==="class"?"class"+(n.group?" in "+esc(n.group):""):"feature, "+esc(n.sub))+(n.tip?": "+esc(n.tip):"")+(links.length?"
              "+links.slice(0,6).join(" · "):"")+(near.length?"
              "+(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 '

              '+esc(t.slice(0,l))+''+esc(t.slice(l))+''+s.n.kind+''+esc(s.n.kind==="class"?(s.n.tip||s.n.group||""):s.n.label+" · "+s.n.sub)+''}).join("")+'
              Tab takes it · ↑↓ choose · Esc closes'; 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()})();