---
description: Mockup, a copy of base d2's live Page:Focus with the layout of the Journeys mockup (Page:Mockup-flows tab 2): the brain on the left, on the right the editor at the top and the notes (the thoughts) under it, the 3D fold arrows between; everything else is Page:Focus as published. Open as /page/Mockup-focus-split?project=<project>&name=<handle>-<n> (razie, 2026-10-12, designer-1)
tags: page, mockup, wip
---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>Focus</title>
<style>
:root{--paper:#fbfaf7;--card:#fff;--ink:#1d1d1b;--soft:#6b6a66;--line:#e4e1da;--indigo:#9a4b12;--indigo-bg:#f5e8dc;--slate:#6b6a66;--slate-bg:#f3f1ec;--sun:#d99a2b;--sun-bg:#fbf1dc;--leaf:#3f7d4e;--rose:#b23a48;--rose-bg:#f8e6e6;--god:#7b3fb8;--attn:#7a7fc9;--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;color-scheme:light dark;
--th-focus:#1f5bb5;--th-chat:#2f6fdc;--th-done:#2e8b57;--th-ask:var(--rose);--th-monitor:var(--god);--th-sent:var(--soft);--th-amber:#d99a00;--accent:var(--th-done)}
@media (prefers-color-scheme:dark){:root{--paper:#141413;--card:#1d1d1b;--ink:#ecebe6;--soft:#9c9a93;--line:#33322f;--indigo:#f0a35e;--indigo-bg:#2e2419;--slate:#9c9a93;--slate-bg:#22211f;--sun:#e5b454;--sun-bg:#2c2515;--leaf:#6cc08a;--rose:#f08a92;--rose-bg:#3a2022;--god:#c5a0f5;--attn:#9296f5;--th-focus:#7aa7f0;--th-chat:#7aa7f0;--th-done:#6cc08a}}
:root.dk{--th-focus:#7aa7f0;--th-chat:#7aa7f0;--th-done:#6cc08a}:root.lt{--th-focus:#1f5bb5;--th-chat:#2f6fdc;--th-done:#2e8b57}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);font:calc(16px*var(--fz,1))/1.5 var(--body)}
a{color:inherit}[hidden]{display:none!important}
.wrap{max-width:1280px;margin:0 auto;padding:6px clamp(12px,3vw,28px) 48px}
h1{font:800 calc(clamp(26px,4vw,38px)*var(--fz,1))/1.1 var(--display);letter-spacing:-.02em;margin:10px 0 12px}
h1 small{font:600 calc(15px*var(--fz,1)) var(--mono);color:var(--soft);letter-spacing:0;vertical-align:middle;margin-left:8px}
.small{font-size:calc(13px*var(--fz,1));color:var(--soft)}.bad{color:var(--rose)}
code{font:calc(14px*var(--fz,1)) var(--mono)}
button,.btn{font:600 calc(14px*var(--fz,1)) var(--body);color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:4px 11px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
button:hover,.btn:hover{border-color:var(--indigo)}
.btn.primary{background:var(--ink);color:var(--paper);border-color:var(--ink)}.btn.primary:hover{filter:brightness(1.15)}
.btn.danger{color:var(--rose);border-color:color-mix(in srgb,var(--rose) 45%,transparent)}button[disabled]{opacity:.5;cursor:default}
.btn.sm{font-size:calc(12px*var(--fz,1));padding:1px 8px;min-height:24px;border-radius:6px}
.ibtn{background:none;border:0;color:var(--soft);padding:2px 6px;border-radius:6px;font:inherit;min-height:0;cursor:pointer}.ibtn:hover{color:var(--ink);background:var(--line)}
.lnk{background:none;border:0;padding:0;font:inherit;color:inherit;text-decoration:underline;cursor:pointer}
select{font:600 calc(13px*var(--fz,1)) var(--body);color:var(--ink);background:var(--card);border:1px solid var(--line);border-radius:8px;padding:2px 8px;max-width:100%}
.tabs{display:inline-flex;flex-wrap:wrap;border:1px solid var(--line);border-radius:999px;overflow:hidden;font:600 calc(13px*var(--fz,1)) var(--body)}
.tabs>button{padding:3px 14px;border:0;border-left:1px solid var(--line);border-radius:0;background:none;font:inherit}.tabs>:first-child{border-left:0}
.tabs>[aria-selected=true]{background:var(--indigo-bg);color:var(--indigo)}
.th-filters{display:none;margin:0 0 10px;background:var(--th-chat);color:#fff;border-color:var(--th-chat)}
.th-grid{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}
.th-side{position:sticky;top:12px;max-height:calc(100vh - 24px);overflow-y:auto;padding-right:4px;font-size:calc(15px*var(--fz,1))}
.th-side h3{font-size:calc(12px*var(--fz,1));text-transform:uppercase;letter-spacing:.04em;color:var(--soft);margin:16px 0 6px}
.th-side>div:first-child h3{margin-top:0}
.th-side ul{list-style:none;padding:0;margin:0}.th-side li{margin:0 0 2px}
.th-side li a{display:flex;justify-content:space-between;gap:8px;padding:3px 8px;border-radius:8px;color:var(--ink);text-decoration:none}
.th-side li a:hover,.th-side li a.on{background:var(--card)}.th-side li a.on{font-weight:600}
.th-side .n{color:var(--soft);font-variant-numeric:tabular-nums}
.th-find{width:100%;font:400 calc(14px*var(--fz,1)) var(--body);padding:4px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);margin:0 0 6px}
.th-tags a span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.th-tags a.th-tag.on{background:color-mix(in srgb,var(--indigo) 12%,transparent);color:var(--indigo);font-weight:600}
.th-tags a.th-tag.on span:first-child::before{content:"✓ "}
.th-tags a.th-tag.red{color:var(--rose)}.th-tags a.th-tag.red span:first-child{text-decoration:line-through}
.th-tags a.th-tag.red span:first-child::before{content:"⊘ ";display:inline-block;text-decoration:none;margin-right:2px}
.th-alltags{margin-top:6px}
.th-hiding,.th-qline{display:flex;justify-content:space-between;gap:10px;margin:0 0 10px}
.th-cap{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin:0 0 18px}
.th-cap .th-tx,.th-pop .th-edit .th-tx{width:100%;font:calc(15px*var(--fz,1))/1.45 var(--body);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);resize:vertical}
.th-cap .th-tx{min-height:3.2em;max-height:60vh;overflow-y:auto}
.th-cap .th-tx:focus,.th-pop .th-tx:focus{outline:2px solid var(--accent);outline-offset:-1px}
.th-acts{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:6px}
.th-cap .th-keys,.th-pop .th-keys{margin-left:auto;font-size:calc(11px*var(--fz,1));color:var(--soft)}
.th-chip{display:inline-flex;gap:5px;align-items:center;border:1px solid var(--line);border-radius:999px;padding:0 8px;font-size:calc(12px*var(--fz,1));color:var(--soft);background:var(--paper);text-decoration:none;white-space:nowrap}
.th-cap .th-to{cursor:pointer;font-weight:400}
.th-pick{font:400 calc(13px*var(--fz,1)) var(--body);padding:1px 6px;border:1px solid var(--line);border-radius:999px;background:var(--paper);color:var(--ink)}
.th-cap .th-mode{margin:0 0 6px;color:var(--soft);font-weight:600}
.th-cap:not(.th-editing) .th-edbtn{display:none}.th-cap:not(.th-asking) #th-eans{display:none}
.th-cap.th-editing #th-save,.th-cap.th-editing #th-to,.th-cap.th-editing #th-send{display:none}
.th-cap.th-editing .th-tx{border-color:var(--th-focus)}
.th-draft{display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0 0 6px;padding:3px 10px;border-radius:999px;background:var(--sun-bg);border:1px solid var(--sun);font-size:calc(13px*var(--fz,1));max-width:100%}
.th-draft span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:min(46ch,60vw);color:var(--soft)}
.th-net{margin:6px 0 0;color:var(--rose);font-weight:600}
#th-msg{margin:6px 0 0}#th-msg:empty{display:none}
.th-voice{display:inline-flex;align-self:stretch}
.th-mic{min-width:40px;min-height:30px;padding:0;justify-content:center;font-size:calc(20px*var(--fz,1));line-height:1;border-radius:8px}
.th-mic.on{background:#c62828;border-color:#c62828;color:#fff;animation:th-mic 1.2s ease-in-out infinite;padding:0 8px}
@keyframes th-mic{50%{box-shadow:0 0 0 5px rgba(198,40,40,.25)}}
.th-interim{margin:4px 2px 0;color:var(--soft);font-style:italic;white-space:pre-wrap}
.th-heard{margin:6px 0 0;display:inline-block;padding:2px 10px;border-radius:999px;background:var(--paper);border:1px solid var(--line);color:var(--ink)}
.th-cap.th-hasbrain{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px;align-items:stretch}
.th-capcol{display:flex;flex-direction:column;min-width:0}.th-hasbrain .th-capcol>.th-tx{flex:1 1 auto}
.th-brain{position:relative;min-width:0;min-height:180px;border:1px solid var(--line);border-radius:10px;background:var(--paper);overflow:hidden;display:flex;flex-direction:column}
.th-brain canvas{display:block;width:100%;flex:1;min-height:147px;cursor:pointer}
.th-bcap{margin:0;padding:7px 12px;border-top:1px solid var(--line);font-size:calc(13px*var(--fz,1));color:var(--soft);min-height:2.9em}
.th-bcap b{color:var(--ink)}.th-bcap .th-bn{color:inherit;text-decoration:none;border-bottom:1px dotted currentColor}.th-bcap .th-brel,.th-bcap .th-bpart{opacity:.55}
.th-bfold{position:absolute;top:4px;right:4px;z-index:1}
.th-bmeter{position:absolute;left:8px;bottom:3.6em;width:5px;height:30px;border-radius:3px;background:var(--accent);transform-origin:bottom;transform:scaleY(0)}
.th-brain.th-listening{box-shadow:0 0 0 2px var(--accent),0 0 16px var(--accent)}
.th-cap.th-bc{grid-template-columns:minmax(0,1fr)}.th-cap.th-bc .th-brain{min-height:0}
.th-brain.th-bfolded canvas,.th-brain.th-bfolded .th-bmeter{display:none}.th-brain.th-bfolded .th-bcap{border-top:0;padding-right:36px}
.th-cap.th-bc .th-tx{min-height:10em}
.th-list{columns:230px;column-gap:12px}
.th-card{border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:var(--card);cursor:pointer;display:flex;flex-direction:column;gap:4px;min-width:0;break-inside:avoid;margin:0 0 12px;position:relative}
.th-card:hover{border-color:color-mix(in srgb,var(--indigo) 40%,var(--line))}
.th-card.imp{border-color:var(--rose);box-shadow:inset 3px 0 0 var(--rose)}
.th-card .th-title{font-size:calc(15px*var(--fz,1));font-weight:600;margin:0;overflow-wrap:anywhere;line-height:1.35;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:10;overflow:hidden}
.th-card .th-ansp{margin:6px 0 0;padding-left:8px;border-left:2px solid var(--line);color:var(--ink);font-size:calc(13.5px*var(--fz,1));line-height:1.45;white-space:pre-line;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:10;overflow:hidden}
.th-card .th-prev{margin:0;color:var(--soft);font-size:calc(14px*var(--fz,1));line-height:1.45;white-space:pre-line;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:9;overflow:hidden}
.th-card .th-meta{display:flex;flex-wrap:wrap;gap:4px 8px;align-items:center;margin-top:4px;font-size:calc(13px*var(--fz,1))}
.th-card .th-tl{color:var(--indigo);text-decoration:none}.th-card .th-tl:hover{text-decoration:underline}
.th-card .th-date{margin-left:auto;color:var(--soft);white-space:nowrap}
.th-card .th-st{border:1px solid currentColor;border-radius:999px;padding:0 7px;font-size:calc(12px*var(--fz,1))}
.th-card .t-thread{color:var(--th-chat)}.th-card .t-done{color:var(--th-done)}.th-card .t-ai-question{color:var(--th-ask)}
.th-card .t-monitor{color:var(--th-monitor)}.th-card .t-sent{color:var(--th-sent);border-style:dashed}.th-card .t-waiting{color:var(--th-amber)}.th-card .t-working{color:var(--th-chat)}
.th-card.ask{border-color:var(--th-amber);box-shadow:inset 3px 0 0 var(--th-amber);background:color-mix(in srgb,var(--th-amber) 7%,var(--card))}
.th-card .th-go{color:var(--th-done);border:1px solid currentColor;border-radius:999px;padding:0 7px;font-size:calc(12px*var(--fz,1));font-weight:600}
.th-card .th-wait{margin:2px 0 0;color:var(--th-sent)}
.th-card.sent{box-shadow:inset 4px 0 0 var(--th-amber)}.th-card.working{box-shadow:inset 4px 0 0 var(--th-chat)}
.th-card.sent .th-wait,.th-card.working .th-wait{color:var(--ink);font-weight:600}
.th-rdot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}
.th-card.foc{border-color:var(--th-focus);box-shadow:inset 3px 0 0 var(--th-focus);background:color-mix(in srgb,var(--th-focus) 9%,var(--card))}
.th-card.foc.pin{outline:2px solid var(--th-focus)}
.th-card.foc .th-flink{color:inherit;text-decoration:none}.th-card.foc .th-title{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden}
.th-fmark{font:600 calc(11px*var(--fz,1)) var(--mono);letter-spacing:.08em;color:var(--th-focus)}
.th-infocus{display:inline-flex;align-items:center;border-radius:999px;padding:0 8px;font-size:calc(11px*var(--fz,1));color:var(--th-focus);background:color-mix(in srgb,var(--th-focus) 9%,var(--card));text-decoration:none;white-space:nowrap}
.th-tick{margin-right:6px}.th-tick input{vertical-align:middle;width:16px;height:16px}
@keyframes thflash{0%,100%{background-color:var(--card)}50%{background-color:var(--sun-bg)}}.th-card.th-flash{animation:thflash 1.2s ease-in-out 2}
.th-empty{color:var(--soft)}
.th-flowc{margin:0 0 8px}.th-flowc b{color:var(--ink)}
.th-sec{margin:0 0 18px}
.th-sec h2{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:calc(13px*var(--fz,1));text-transform:uppercase;letter-spacing:.04em;color:var(--soft);margin:0 0 8px}
.th-sec h2 .n{font-variant-numeric:tabular-nums}.th-sec h2 .btn{text-transform:none;letter-spacing:0}
.th-sec h2 .th-sendin{margin-left:auto}
.th-ask{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 10px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:var(--card)}
.th-ask input{flex:1 1 180px;font:calc(14px*var(--fz,1)) var(--body);padding:4px 8px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}
.th-sent-ok{color:var(--th-done);font-weight:600}
#th-more{margin:6px 0 0}
#th-main{position:relative;min-width:0}
.th-pop{position:absolute;z-index:20;max-width:100%;border:1px solid var(--line);border-radius:12px;padding:10px 12px;background:var(--card);box-shadow:0 10px 30px rgba(0,0,0,.25)}
.th-pop .th-edit .th-tx{min-height:8em}
.th-pop .th-edhead{display:flex;align-items:center;gap:6px;margin:-2px 0 6px}
.th-pop .th-vsw{display:inline-flex;margin-right:auto;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.th-pop .th-vb{border:0;background:none;padding:2px 10px;font:calc(12px*var(--fz,1)) var(--body);color:var(--soft);border-radius:0}
.th-pop .th-vb.on{background:var(--paper);color:var(--ink);font-weight:600}
.th-pop .th-view{min-height:8em;max-height:60vh;overflow-y:auto;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);font-size:calc(15px*var(--fz,1));overflow-wrap:anywhere}
.th-pop .th-vtail{min-height:2.4em;cursor:text}
.th-pop .th-acts>*{white-space:nowrap}.th-pop .th-kebab{margin-left:auto;font-size:calc(18px*var(--fz,1));line-height:1;padding:0 8px}
.th-menu{position:absolute;right:8px;z-index:30;min-width:220px;max-width:calc(100% - 16px);display:flex;flex-direction:column;padding:6px;border:1px solid var(--line);border-radius:10px;background:var(--card);box-shadow:0 8px 24px rgba(0,0,0,.2)}
.th-menu>button{justify-content:flex-start;border:0;background:none;padding:7px 10px;font-weight:500;border-radius:6px;text-align:left}
.th-menu>button:hover{background:var(--paper)}.th-menu>button.danger{color:var(--rose)}
.th-menu .th-mrow{display:flex;align-items:center;gap:6px;padding:5px 10px;font-size:calc(13px*var(--fz,1));color:var(--soft)}
.th-menu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
.th-foc{display:flex;flex-direction:column;gap:4px;margin-top:8px;padding:8px;border:1px solid var(--line);border-left:3px solid var(--th-focus);border-radius:10px;background:var(--paper)}
.th-foc .th-in{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:calc(13px*var(--fz,1))}
.th-foc .th-in b{color:var(--th-focus)}
.th-foc .th-fl{display:flex;flex-direction:column;max-height:220px;overflow-y:auto}
.th-foc .th-fl button{justify-content:flex-start;border:0;background:none;font-weight:500;padding:5px 8px;text-align:left;border-radius:6px}
.th-foc .th-fl button:hover{background:var(--card)}.th-foc .th-fl .new{color:var(--th-focus);font-weight:700}
.th-foc input{font:calc(14px*var(--fz,1)) var(--body);padding:4px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink)}
.th-pop .th-emsg{margin:6px 0 0}
div[data-part="role-pick"]{display:flex;flex-direction:column;gap:8px;min-width:min(240px,100%);padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--card);box-shadow:0 6px 20px rgba(0,0,0,.12)}
div[data-part="role-pick"] .rpick-row{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 14px;border:1px solid var(--line);border-radius:10px;background:var(--paper);font-weight:500}
div[data-part="role-pick"] .rpick-row.on{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
div[data-part="role-pick"] .rpick-dot{width:12px;height:12px;border-radius:50%;flex:none}
div[data-part="role-pick"] .rpick-x{align-self:flex-end;min-width:44px;min-height:44px;justify-content:center}
.th-concept h2{font:800 calc(22px*var(--fz,1)) var(--display);margin:8px 0 8px;display:flex;flex-wrap:wrap;align-items:baseline;gap:10px}
.th-cbody{margin-top:12px;padding:12px 14px;border:1px solid var(--line);border-radius:12px;background:var(--card);overflow-wrap:anywhere}
.th-cbody h4{margin:14px 0 4px;font-size:calc(14px*var(--fz,1))}.th-cbody h4:first-child{margin-top:0}
.th-cbody ul{margin:4px 0;padding-left:20px}.th-cbody li{margin:2px 0}
.th-cbody .code{font:calc(11px*var(--fz,1)) var(--mono);color:var(--soft);margin-left:4px}
mark{background:var(--sun-bg);color:inherit;border-radius:3px;padding:0 2px}
.md>:first-child{margin-top:0}.md h3,.md h4{font-size:1.05em;margin:.8em 0 .3em}.md p{margin:.4em 0}.md ul,.md ol{margin:.3em 0;padding-left:1.4em}
.md pre{background:var(--slate-bg);padding:8px;border-radius:8px;overflow-x:auto;white-space:pre-wrap}.md blockquote{margin:.4em 0;padding-left:10px;border-left:3px solid var(--line);color:var(--soft)}
.md a{color:var(--indigo)}
.th-modal{position:fixed;inset:0;z-index:50;display:grid;place-items:center;background:rgba(0,0,0,.35);padding:16px}
.th-mbox{max-width:420px;width:100%;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;box-shadow:0 10px 30px rgba(0,0,0,.3)}
.th-mbox h3{margin:0 0 6px;font-size:calc(17px*var(--fz,1))}.th-mbox p{margin:0 0 12px;color:var(--soft)}.th-mbox .th-acts{justify-content:flex-end}
@media (max-width:720px){.th-filters{display:inline-flex}.th-grid{grid-template-columns:minmax(0,1fr)}.th-side{display:none;position:static;max-height:none;overflow:visible;border:1px solid var(--line);border-radius:12px;background:var(--card);padding:10px 12px}body.th-open .th-side{display:block}.th-list{columns:1}.th-cap.th-hasbrain{grid-template-columns:minmax(0,1fr)}}
@media (max-width:600px){.th-cap .th-tx{min-height:calc(4 * 1.45em + 18px)}.th-cap .th-keys{display:none}.th-pop{left:0!important;width:100%!important}}
@media (prefers-reduced-motion:reduce){.th-mic.on,.th-card.th-flash{animation:none}}
.th-thread{display:flex;flex-direction:column;max-height:85vh}.th-thead{display:flex;gap:8px;align-items:center;margin:-2px 0 8px}.th-thead b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.th-thread .th-tbody{max-height:58vh;min-height:4em}.th-tfoot{margin-top:10px;padding-top:10px;border-top:1px solid var(--line)}.th-tfoot .th-tx{width:100%;font:calc(15px*var(--fz,1))/1.45 var(--body);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);resize:vertical;min-height:4.5em}.th-tfoot .th-tx:focus{outline:2px solid var(--accent);outline-offset:-1px}.th-tfoot .th-acts{display:flex;gap:8px;align-items:center;margin-top:6px}.th-thread.th-tbig{box-shadow:0 10px 40px rgba(0,0,0,.3)}.th-thread.th-tbig .th-tbody{max-height:65vh}.th-capcol.th-threading>*:not(.th-thread){display:none!important}.th-thread.th-incap{flex:1 1 auto}.th-thread.th-incap .th-tbody{max-height:65vh}.th-thread.th-incap .th-tbody{max-height:16em;min-height:6em}.th-thread.th-incap .th-tfoot .th-tx{min-height:3.2em}.th-thread .th-tbody{overflow-y:auto;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);font-size:calc(15px*var(--fz,1));overflow-wrap:anywhere}.th-brain canvas{height:0;flex:1 1 auto}#th-body.th-stale{opacity:.55;pointer-events:none;transition:opacity .2s}.d2flow{display:inline-flex;align-items:center;margin-left:8px;padding:5px 9px;border:1px solid var(--line);border-radius:999px;background:var(--card);color:var(--soft);font:600 12px/1 var(--body);letter-spacing:0;vertical-align:middle;cursor:pointer}.d2flow[data-flow="on"]{border-color:transparent;background:color-mix(in srgb,var(--flow,var(--th-focus,#1f5bb5)) 12%,transparent);color:var(--flow,var(--th-focus,#1f5bb5))}.th-info{position:relative;display:inline-block;margin-left:6px;vertical-align:middle}.th-ib{width:22px;height:22px;padding:0;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--soft);font:600 12px/1 var(--body);cursor:pointer}.th-ip{position:absolute;z-index:30;left:0;top:28px;width:min(340px,80vw);padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--card);color:var(--ink);font:400 14px/1.45 var(--body);letter-spacing:0;box-shadow:0 8px 24px rgba(0,0,0,.18)}.th-ip[hidden]{display:none}.th-ih{display:block;margin-top:6px;color:var(--soft)}.th-ih a{color:var(--indigo)}.sk{display:inline-block;height:.8em;background:var(--line);border-radius:4px;opacity:.7;vertical-align:middle}.th-card.th-skel{display:flex;flex-direction:column;gap:9px;min-height:7.5em;break-inside:avoid}.th-card.th-skel .sk{display:block}.wrap>h1{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}.wrap>h1 small{margin-left:0}.wrap>h1 .d2flow,.wrap>h1 .th-info{margin-left:0}.th-bload{position:absolute;left:0;right:0;top:0;bottom:4.6em;display:grid;place-items:center;margin:0;color:var(--soft);font-size:calc(14px*var(--fz,1));pointer-events:none}.th-bcap{min-height:calc(3 * 1.45em + 14px)}.th-noq{margin:0 0 6px;color:var(--soft)}.th-card.rep{border-left:4px solid var(--leaf,#3f7d4e);background:color-mix(in srgb,var(--leaf,#3f7d4e) 7%,var(--card))} .th-tx{display:block;box-sizing:border-box;white-space:pre-wrap;overflow-wrap:anywhere;overflow-y:auto;text-align:left;cursor:text;-webkit-user-select:text;user-select:text} .th-tx:empty::before{content:attr(data-ph);color:var(--soft);pointer-events:none} .th-tfoot .th-tx{min-height:4.5em}
/* The Focus view (SpecUi ^thoughts-ui-24..29, -31): the head, the two-lobed map, the phone strip, the editor under them.
On d2's tokens; the ring, the core and the current node are the Focus blue. Nothing here moves by itself: motion is
the page script's, and a jump under prefers-reduced-motion. */
.fo{max-width:900px;margin:0 auto}
.fo-back{margin:0 0 4px}.fo-back a{color:var(--soft);text-decoration:none}
.fo-head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;margin:0 0 8px}
.fo-head h1{margin:0;font-size:calc(22px*var(--fz,1))}
.fo-mark{font:600 calc(11px*var(--fz,1)) var(--mono,ui-monospace,monospace);letter-spacing:.08em;color:var(--th-focus);border:1px solid var(--th-focus);border-radius:999px;padding:1px 8px;background:color-mix(in srgb,var(--th-focus) 9%,var(--card))}.fo-ab{display:inline-flex;border:1px solid var(--th-focus);border-radius:999px;overflow:hidden;vertical-align:middle}.fo-ab button{border:0;background:var(--card);color:var(--th-focus);font:600 calc(11px*var(--fz,1)) var(--mono,ui-monospace,monospace);padding:1px 9px;cursor:pointer;min-height:22px}.fo-ab button.on{background:var(--th-focus);color:var(--card)}
.fo-sent{color:var(--th-sent,var(--soft));font-size:calc(13px*var(--fz,1));border:1px dashed currentColor;border-radius:999px;padding:0 8px}
.fo-sp{flex:1}
.fo-addw{position:relative}
#fo-addmenu{position:absolute;right:0;top:calc(100% + 4px);z-index:5;display:flex;flex-direction:column;min-width:170px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:4px;box-shadow:0 6px 20px rgb(0 0 0 / .12)}
#fo-addmenu[hidden]{display:none}
#fo-addmenu a{padding:6px 10px;border-radius:7px;color:var(--ink);text-decoration:none;cursor:pointer}
#fo-addmenu a:hover{background:color-mix(in srgb,var(--th-focus) 9%,var(--card))}
#fo-sendask{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 10px;padding:8px 12px;border:1px solid var(--th-chat);border-radius:10px;background:color-mix(in srgb,var(--th-chat) 7%,var(--card))}
#fo-sendask[hidden]{display:none}
#fo-map{display:block;width:100%;height:auto;max-height:440px;user-select:none}
#fo-map{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:6px;box-sizing:border-box} #fo-map.fo-bmap{padding:0;overflow:hidden;max-height:none;height:auto} #fo-map canvas{display:block;width:100%;height:min(62vh,560px)} .fo-btools{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid var(--line)} .fo-bplus{border:0;border-radius:999px;color:#fff;font-weight:700;padding:3px 11px;cursor:pointer;white-space:nowrap;font-size:calc(13px*var(--fz,1))} .fo-bplus.dom{background:#8e5cc8}.fo-bplus.fea{background:#1f8a8c}.fo-bplus.top{background:#8a8a8a} .fo-blegend{margin-left:auto;color:var(--soft);font-size:calc(12px*var(--fz,1))}@media (max-width:600px){.fo-blegend{display:none}}
#fo-map .fo-lobe{fill:color-mix(in srgb,var(--th-focus) 6%,var(--card));stroke:var(--line);stroke-width:1.5}
#fo-map .fo-cap{font:600 calc(10px*var(--fz,1)) var(--mono,ui-monospace,monospace);letter-spacing:.08em;fill:var(--soft);text-anchor:middle}
#fo-map .fo-spoke,#fo-map .fo-sspoke{stroke:color-mix(in srgb,var(--soft) 45%,var(--line));stroke-width:2;fill:none}
#fo-map .fo-spoke.on{stroke:var(--th-focus);stroke-width:3}
#fo-map .fo-link{stroke:color-mix(in srgb,var(--soft) 45%,var(--line));stroke-width:1.75;stroke-dasharray:3 5;stroke-linecap:round;fill:none}
#fo-map .fo-node,#fo-map .fo-core,#fo-map .fo-stack{cursor:pointer;outline:none}
#fo-map .fo-node a,#fo-map .fo-core a{text-decoration:none}
#fo-map .fo-shape{fill:var(--card);stroke:var(--th-focus);stroke-width:2}
#fo-map .fo-node[data-kind="topic"] .fo-shape{stroke:var(--indigo,#3b3fb6)}
#fo-map .fo-node.sel .fo-shape{fill:color-mix(in srgb,var(--th-focus) 14%,var(--card));stroke-width:3}
#fo-map .fo-node:focus-visible .fo-shape,#fo-map .fo-core:focus-visible circle{stroke-width:3}
#fo-map .fo-num{font:600 calc(12px*var(--fz,1)) var(--mono,ui-monospace,monospace);fill:var(--soft);text-anchor:middle;pointer-events:none}
#fo-map .fo-name{font:500 calc(12px*var(--fz,1)) var(--body);fill:var(--soft);text-anchor:middle;pointer-events:none}
#fo-map .fo-name.sm{font-size:calc(11px*var(--fz,1))}
#fo-map .fo-node.sel .fo-name{font-weight:700;fill:var(--ink)}
#fo-map .fo-node.gone .fo-name{text-decoration:line-through;fill:var(--soft)}
#fo-map .fo-node.gone .fo-shape{stroke-dasharray:3 3;stroke:var(--soft)}
#fo-map .fo-core circle{fill:var(--th-focus)}
#fo-map .fo-core text{font:600 calc(13px*var(--fz,1)) var(--body);fill:#fff;text-anchor:middle;pointer-events:none}
#fo-map .fo-stack rect{fill:var(--card);stroke:var(--indigo,#3b3fb6);stroke-width:1.3}
#fo-ring{fill:none;stroke:var(--th-focus);stroke-width:2.5;opacity:.55;pointer-events:none}
#fo-stacklist{list-style:none;margin:0 0 8px;padding:0;display:flex;flex-wrap:wrap;gap:6px}
#fo-stacklist[hidden]{display:none}
#fo-stacklist a{display:inline-block;border:1px solid var(--line);border-radius:7px;padding:2px 8px;text-decoration:none;color:var(--ink);font-size:calc(13px*var(--fz,1))}
.fo-nav,.fo-pnav{display:flex;align-items:center;justify-content:center;gap:12px;margin:6px 0 10px}
.fo-pnav{display:none;margin:0 0 0 auto}
#fo-count{color:var(--soft);font-variant-numeric:tabular-nums;min-width:7em;text-align:center}
#fo-strip{display:flex;gap:6px;overflow-x:auto;padding:4px 0 8px;margin:0 0 8px;scrollbar-width:thin}
.fo-chip{flex:none;display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--th-focus);border-radius:999px;padding:7px 10px;font:600 calc(12px*var(--fz,1))/1 var(--body);color:var(--ink);background:var(--card);text-decoration:none;white-space:nowrap}
.fo-chip[data-kind="topic"]{border-color:var(--indigo,#3b3fb6);border-radius:7px}
.fo-chip .fo-sh{display:inline-block;width:9px;height:9px;border-radius:50%;border:1.5px solid var(--th-focus);flex:none}
.fo-chip[data-kind="topic"] .fo-sh{border-radius:2px;border-color:var(--indigo,#3b3fb6)}
.fo-chip.f{background:var(--th-focus);color:#fff}
.fo-chip.on{background:color-mix(in srgb,var(--th-focus) 14%,var(--card));box-shadow:0 0 0 2px var(--th-focus)}
.fo-chip.f.on{background:var(--th-focus);box-shadow:0 0 0 2px var(--card),0 0 0 4px var(--th-focus)}
.fo-chip.gone{text-decoration:line-through;color:var(--soft)}
#fo-empty{border:1px dashed var(--th-focus);border-radius:12px;padding:12px 16px;margin:0 0 12px;background:color-mix(in srgb,var(--th-focus) 5%,var(--card))}
#fo-empty p{margin:4px 0}
#fo-ed{border:1px solid var(--th-focus);border-radius:10px;padding:10px 12px;background:var(--card)}
.fo-edhead{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:0 0 6px}
.fo-kind{font:600 calc(11px*var(--fz,1))/1.3 var(--mono,ui-monospace,monospace);letter-spacing:.06em;color:var(--th-focus)}
.fo-also{font-size:calc(12px*var(--fz,1));color:var(--soft)}
.fo-saved{margin-left:auto;font-size:calc(12px*var(--fz,1));color:var(--th-done,#2e8b57)}
#fo-text{width:100%;box-sizing:border-box;min-height:12em;font:calc(15px*var(--fz,1)) var(--body);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);resize:vertical}
#fo-text[readonly]{background:color-mix(in srgb,var(--soft) 6%,var(--card))}
.fo-acts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:8px}
#fo-mergelist{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px;flex-basis:100%}
#fo-mergelist[hidden]{display:none}
.fo-gonemsg{color:var(--soft);margin:4px 0}
@media (max-width:599px){#fo-map,.fo-nav{display:none}.fo-pnav{display:flex}}
@media (min-width:600px){#fo-strip{display:none}}
/* Add and Send focus on the editor's bar, at its right; on a phone the bar takes its own line under the name */
.fo-edhead .fo-bar{margin-left:auto;display:inline-flex;gap:6px;align-items:center}
@media (max-width:599px){.fo-edhead .fo-bar{margin-left:0;flex-basis:100%}}
/* the markdown toolbar over the Focus's boxes */
.fo-tools{display:flex;flex-wrap:wrap;gap:2px;margin:0 0 4px}
.fo-tool{min-width:28px;font:600 calc(13px*var(--fz,1)) var(--body);border:1px solid var(--line);border-radius:6px;background:var(--paper);color:var(--ink);padding:2px 6px;cursor:pointer}
#fo-newbox{border:1px dashed var(--th-focus);border-radius:8px;padding:8px;margin:0 0 8px}
#fo-newbox[hidden]{display:none}
#fo-newtext{width:100%;box-sizing:border-box;min-height:5em;font:calc(15px*var(--fz,1)) var(--body);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}
/* the Focus's thoughts as cards under the editor, then the wrap-up */
#fo-cards{margin-top:14px}#fo-cards h2{font-size:calc(15px*var(--fz,1));margin:0 0 8px}#fo-cards .n{color:var(--soft);font-weight:400}
.fo-cacts{display:flex;gap:6px;align-items:center;justify-content:flex-end;margin-top:4px}
.fo-cst{font-size:calc(12px*var(--fz,1));color:var(--th-chat,var(--soft))}.fo-cst.ok{color:var(--th-done,var(--leaf))}
.fo-wrap{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:flex-end;margin:10px 0}
/* sent: a green card saying what it became */
.fo-head + .fo-sent{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;border:1px solid var(--th-done,var(--leaf));border-style:solid;border-radius:10px;padding:8px 12px;margin:0 0 10px;color:var(--ink);background:color-mix(in srgb,var(--th-done,var(--leaf)) 10%,var(--card))}
.fo-head + .fo-sent b{color:var(--th-done,var(--leaf))}.fo-head + .fo-sent .small{color:var(--soft)}
/* the model lobe (^thoughts-ui-24): concepts violet, features teal, behaviours amber, each its own shade in dark mode;
a legend under the map, and what a tapped node opens; the cards it names light up with an outline */
.fo{--fo-concept:#7a5cc9;--fo-feature:#3f8f8a;--fo-behaviour:#c98a2b}
@media (prefers-color-scheme:dark){.fo{--fo-concept:#a58bf0;--fo-feature:#5cc0b8;--fo-behaviour:#e5b454}}
[data-mk="concept"]{--mk:var(--fo-concept)}[data-mk="feature"]{--mk:var(--fo-feature)}[data-mk="behaviour"]{--mk:var(--fo-behaviour)}
#fo-map .fo-mnode{cursor:pointer;outline:none}#fo-map .fo-name.end{text-anchor:end}
#fo-map .fo-mshape{fill:color-mix(in srgb,var(--mk) 22%,var(--card));stroke:var(--mk);stroke-width:2}
#fo-map .fo-mnode.on .fo-mshape,#fo-map .fo-mnode:focus-visible .fo-mshape{stroke-width:3.5;fill:color-mix(in srgb,var(--mk) 40%,var(--card))}
#fo-map .fo-mspoke{stroke:color-mix(in srgb,var(--soft) 30%,var(--line));stroke-width:1.5;fill:none}
#fo-map .fo-mlink{stroke:color-mix(in srgb,var(--soft) 45%,var(--line));stroke-width:1.25;stroke-dasharray:1 4;stroke-linecap:round;fill:none}
#fo-map .fo-stack[data-kind="model"] rect{stroke:var(--fo-concept)}
.fo-legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin:4px 2px 8px;font-size:calc(12px*var(--fz,1));color:var(--soft)}
.fo-lg{display:inline-flex;align-items:center;gap:5px}.fo-lg i{width:10px;height:10px;border-radius:50%;background:color-mix(in srgb,var(--mk) 22%,var(--card));border:2px solid var(--mk)}
.fo-mchip{flex:none;display:inline-flex;align-items:center;gap:5px;border:1.5px solid var(--mk);border-radius:999px;padding:4px 9px;font:500 calc(12px*var(--fz,1))/1 var(--body);color:var(--ink);background:color-mix(in srgb,var(--mk) 10%,var(--card));white-space:nowrap;cursor:pointer}
.fo-mchip .fo-sh{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--mk);flex:none}.fo-mchip.on{box-shadow:0 0 0 2px var(--mk)}
#fo-mstack{list-style:none;margin:0 0 8px;padding:0;display:flex;flex-wrap:wrap;gap:6px}#fo-mstack[hidden]{display:none}
.fo-mopen{border:1px solid var(--mk);border-radius:10px;padding:10px 12px;margin:0 0 10px;background:color-mix(in srgb,var(--mk) 5%,var(--card))}.fo-mopen[hidden]{display:none}
.fo-mopen .fo-kind{color:var(--mk)}.fo-mtext{white-space:pre-wrap;font-size:calc(14px*var(--fz,1));max-height:16em;overflow:auto}
@keyframes fomlit{0%,100%{outline-color:transparent}50%{outline-color:var(--lit,var(--fo-concept))}}.th-card.fo-mlit{outline:3px solid transparent;outline-offset:2px;animation:fomlit .9s ease-in-out 2}
@media (prefers-reduced-motion:reduce){.th-card.fo-mlit{animation:none;outline-color:var(--lit,var(--fo-concept))}}div[data-part="role-pick"]{display:flex;flex-direction:column;gap:8px;min-width:min(240px,100%);max-width:100%;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--card,var(--paper));box-shadow:0 6px 20px rgba(0,0,0,.12)}
div[data-part="role-pick"] .rpick-row{display:flex;align-items:center;gap:10px;min-height:44px;margin:0;padding:0 14px;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--ink);font:500 calc(15px*var(--fz,1)) var(--body);text-align:left;cursor:pointer}
div[data-part="role-pick"] .rpick-row:hover{background:var(--bg2,var(--paper))}div[data-part="role-pick"] .rpick-row.on{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
div[data-part="role-pick"] .rpick-dot{width:12px;height:12px;border-radius:50%;flex:none}
div[data-part="role-pick"] .rpick-x{align-self:flex-end;min-width:44px;min-height:44px}.d2c-btn{position:fixed;right:18px;bottom:12vh;z-index:60;width:48px;height:48px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--ink);font-size:22px;box-shadow:0 4px 14px rgba(0,0,0,.18);cursor:pointer}
.d2c-btn.d2c-blue{background:var(--th-chat,#2f6fdc);border-color:var(--th-chat,#2f6fdc);color:#fff}
.d2c-btn.live::after{content:"";position:absolute;left:-2px;bottom:-2px;width:12px;height:12px;border-radius:50%;background:var(--leaf,#2f9a6f);border:2px solid var(--card)}
.d2c-btn.draft::before{content:"";position:absolute;left:-2px;top:-2px;width:12px;height:12px;border-radius:50%;background:var(--sun,#d99a1e);border:2px solid var(--card)}
.d2c-btn .d2c-n{position:absolute;top:-4px;right:-4px;min-width:18px;padding:1px 5px;border-radius:999px;background:var(--rose,#c0392b);color:#fff;font:700 11px/16px var(--body)}
.d2c-box{position:fixed;right:18px;bottom:calc(12vh + 58px);z-index:61;width:min(380px,calc(100vw - 36px));max-width:100vw;max-height:min(560px,calc(88vh - 76px));overflow-x:hidden;box-sizing:border-box;display:flex;flex-direction:column;border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--ink);box-shadow:0 8px 28px rgba(0,0,0,.22);font-size:calc(14px*var(--fz,1))}
.d2c-head{display:flex;flex-wrap:wrap;flex:none;align-items:center;gap:6px;padding:4px 10px;min-height:0;border-bottom:1px solid var(--line);border-radius:12px 12px 0 0}.d2c-head b{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.d2c-head .d2c-sp{flex:1}
.d2c-dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--soft);flex:none}.d2c-dot.live{background:var(--leaf,#2f9a6f)}.d2c-dot.asked{background:var(--sun,#d99a1e)}
.d2c-sub{flex:none;padding:6px 10px 4px;color:var(--soft);font-size:calc(12px*var(--fz,1))}.d2c-subj{display:block;color:var(--ink);font-weight:600;font-size:calc(16px*var(--fz,1));line-height:1.3;overflow-wrap:anywhere}
.d2c-msgs{flex:1;overflow:auto;padding:8px 10px;display:flex;flex-direction:column;gap:6px;min-height:120px}
.d2c-m{position:relative;max-width:85%;padding:6px 26px 6px 10px;border-radius:12px;background:var(--slate-bg,#eef1f5);white-space:pre-wrap;word-wrap:break-word;overflow-wrap:anywhere;align-self:flex-start}.d2c-t a{color:var(--accent,#2f6fdc);text-decoration:underline}.d2c-m.me{align-self:flex-end;background:var(--indigo-bg,#eceafd)}
.d2c-cp{position:absolute;top:2px;right:4px;border:0;background:none;color:var(--soft);font-size:12px;cursor:pointer;padding:0}
.d2c-st{color:var(--soft);font-style:italic;font-size:calc(12px*var(--fz,1))}
.d2c-in{display:flex;flex:none;gap:6px;align-items:flex-end;padding:8px 10px;border-top:1px solid var(--line)}.d2c-in textarea{flex:1;min-width:0;min-height:66px;max-height:140px;resize:vertical;font:inherit}.d2c-side{display:flex;flex-direction:column;gap:6px;align-items:stretch}
.d2c-wave{display:inline-flex;gap:2px;align-items:center;height:16px}.d2c-wave i{display:block;width:3px;height:6px;border-radius:2px;background:currentColor}.d2c-wave i:nth-child(2){height:12px}.d2c-wave i:nth-child(3){height:9px}
.d2c-mic[aria-pressed=true]{color:var(--rose,#c0392b)}.d2c-mic[aria-pressed=true] .d2c-wave i{animation:d2cwave .9s ease-in-out infinite}.d2c-mic[aria-pressed=true] .d2c-wave i:nth-child(2){animation-delay:.15s}.d2c-mic[aria-pressed=true] .d2c-wave i:nth-child(3){animation-delay:.3s}.d2c-mic[aria-pressed=true] .d2c-wave i:nth-child(4){animation-delay:.45s}
@keyframes d2cwave{0%,100%{transform:scaleY(.5)}50%{transform:scaleY(1.6)}}
.d2c-after,.d2c-warn,.d2c-first{padding:10px;border-top:1px solid var(--line)}.d2c-after p,.d2c-warn p,.d2c-first p{margin:0 0 8px}.d2c-acts{display:flex;gap:6px;flex-wrap:wrap}
.d2c-row{display:flex;gap:8px;align-items:flex-start;padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer}.d2c-row:hover{background:var(--slate-bg,#eef1f5)}.d2c-row .d2c-l{flex:1;min-width:0}.d2c-row .d2c-l div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.d2c-row .d2c-u{min-width:18px;padding:1px 5px;border-radius:999px;background:var(--rose,#c0392b);color:#fff;font:700 11px/16px var(--body);text-align:center}
.d2c-past{display:block;width:100%;padding:8px 10px;border:0;border-bottom:1px solid var(--line);background:none;color:var(--soft);text-align:left;cursor:pointer;font:inherit}
.d2c-foot{display:flex;gap:8px;align-items:center;padding:8px 10px;color:var(--soft);font-size:calc(12px*var(--fz,1))}.d2c-foot span{flex:1}
.d2c-chip{border-style:dashed!important}.d2c-chip[aria-disabled=true]{opacity:.5;cursor:not-allowed}
.d2c-pop{margin:6px 0;padding:10px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.d2c-pop p{margin:0 0 6px}.d2c-pop input{width:100%;margin:2px 0 8px}
.d2c-tint{background:color-mix(in srgb,var(--th-chat,#2f6fdc) 15%,var(--card))}
.d2c-box[hidden]{display:none!important}
@media (max-width:600px){.d2c-box{left:0;right:0;bottom:0;width:100%;max-height:45vh;border-radius:12px 12px 0 0}.d2c-msgs{min-height:48px}}div[data-part="role-pick"]{display:flex;flex-direction:column;gap:8px;min-width:min(240px,100%);max-width:100%;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--card,var(--paper));box-shadow:0 6px 20px rgba(0,0,0,.12)}
div[data-part="role-pick"] .rpick-row{display:flex;align-items:center;gap:10px;min-height:44px;margin:0;padding:0 14px;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--ink);font:500 calc(15px*var(--fz,1)) var(--body);text-align:left;cursor:pointer}
div[data-part="role-pick"] .rpick-row:hover{background:var(--bg2,var(--paper))}div[data-part="role-pick"] .rpick-row.on{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
div[data-part="role-pick"] .rpick-dot{width:12px;height:12px;border-radius:50%;flex:none}
div[data-part="role-pick"] .rpick-x{align-self:flex-end;min-width:44px;min-height:44px}
.fo-tp{--mk:var(--indigo,#3b3fb6)}.fo-rp{--mk:var(--soft)}.fo-rp.added{--mk:var(--indigo,#3b3fb6)}#fo-map .fo-rel{cursor:pointer}#fo-map .fo-rel.on circle{stroke-width:3}#fo-map .fo-rel.added circle{stroke:var(--indigo,#3b3fb6);fill:color-mix(in srgb,var(--indigo,#3b3fb6) 20%,var(--card))}#fo-map .fo-rel.added text{fill:var(--indigo,#3b3fb6)}.fo-tp .fo-kind{color:var(--mk)}
.fo-sw{display:inline-flex;border:1px solid var(--line);border-radius:999px;overflow:hidden;font-size:11px;margin-right:6px;vertical-align:middle}.fo-sw span{padding:2px 8px}.fo-sw .on{background:var(--indigo-bg,#f5e8dc);color:var(--indigo,#9a4b12);font-weight:700}
.fo-relg circle{fill:none;stroke:#9c9a93;stroke-dasharray:3 3}.fo-relg text,.fo-relgcap{fill:#9c9a93;font-size:12px}.fo-head .btn{margin-left:6px}.fo-brain{position:relative}.fo-bfold{position:absolute;top:2px;right:2px;z-index:1}.fo-bcap{border:1px solid var(--line);border-radius:10px;margin:2px 0 6px;min-height:0;padding:6px 40px 6px 12px;background:var(--paper)}.fo-brain.fo-bfolded{position:sticky;top:0;z-index:5;background:var(--paper)}.fo-brain.fo-bfolded #fo-map,.fo-brain.fo-bfolded+p.small{display:none}.fo-brain.fo-bfolded .fo-bcap{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.th-card.fo-gone{opacity:0;transform:scale(.96);transition:opacity .25s,transform .25s}#fo-outn{margin-left:8px;vertical-align:middle}.th-card.fo-picked{outline:2px solid var(--rose,#b23a48);outline-offset:-2px}#fo-pickbar{position:sticky;bottom:10px;z-index:6;display:flex;gap:10px;align-items:center;margin:10px auto;width:max-content;max-width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:999px;background:var(--card);box-shadow:0 4px 18px rgba(0,0,0,.18)}#fo-ask{border:1px solid var(--line);border-radius:14px;padding:16px 18px;max-width:420px;background:var(--card);color:var(--ink)}#fo-ask::backdrop{background:rgba(0,0,0,.35)}#fo-ask p{margin:0 0 12px;line-height:1.45}#fo-asksw{margin:0 0 12px}#fo-asksw span{cursor:pointer}.fo-askb{display:flex;gap:8px;justify-content:flex-end}#fo-askgo.fo-danger{background:var(--rose,#b23a48);border-color:var(--rose,#b23a48)}.fo-head #fo-send{margin-right:6px}main.fo .btn.sm:not(.primary),#fo-ask .btn.sm:not(.primary){background:color-mix(in srgb,var(--th-focus,#1f5bb5) 22%,var(--card));border-color:color-mix(in srgb,var(--th-focus,#1f5bb5) 55%,var(--line))}main.fo .btn.sm:not(.primary):hover,#fo-ask .btn.sm:not(.primary):hover{background:color-mix(in srgb,var(--th-focus,#1f5bb5) 32%,var(--card))}#fo-outn[hidden]{display:none!important}/* Page:Focus (P-1146): what the static mockup didn't need */
.fo-head{position:relative}.fo-head .th-info{position:relative;display:inline-block}
.fo-cbody{white-space:pre-wrap;font-size:calc(14px*var(--fz,1));color:var(--ink);max-height:9.5em;overflow:hidden}
.th-card.fo-open{cursor:default;border-color:var(--th-focus)}.th-card.fo-open .fo-cbody{display:none}
.fo-cedit{width:100%;min-height:6em;font:calc(15px*var(--fz,1)) var(--body);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink)}
.fo-cacts{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:6px}
.fo-sentb{display:inline-block;padding:2px 8px;border-radius:999px;background:color-mix(in srgb,var(--th-done) 18%,var(--card));color:var(--th-done);font-weight:700;font-size:calc(12px*var(--fz,1))}
.fo-relg{cursor:pointer}.fo-relg:focus,.fo-relg.on circle{stroke:var(--th-focus)}
#fo-ask .fo-sw span{cursor:pointer}
.fo-first{border:1px solid var(--th-focus);border-radius:10px;padding:8px 12px;margin:0 0 10px;background:color-mix(in srgb,var(--th-focus) 8%,var(--card));font-size:calc(14px*var(--fz,1))}
#fo-ed.fo-edfoc{border-left:3px solid var(--th-focus);background:color-mix(in srgb,var(--th-focus) 7%,var(--card));border-radius:12px;padding:8px 10px}#fo-ed.fo-edfoc .fo-kind{color:var(--th-focus)}.fo-privl{margin-left:8px;color:var(--soft)}.th-card.fo-open{outline:2px solid var(--th-focus)}
.fo-sendpick{display:inline-flex;flex-wrap:wrap;gap:4px;margin-left:6px}
.fo-head{flex-wrap:nowrap}.fo-head h1{flex:1 1 auto;min-width:0;overflow-wrap:anywhere}.fo-head .fo-sp{display:none}.fo-hacts{display:inline-flex;flex:none;gap:6px;white-space:nowrap;align-items:center}.fo-hacts .btn{margin:0}
@media (max-width:560px){.fo-head{flex-wrap:wrap}.fo-hacts{width:100%;justify-content:flex-end}}
/* razie, chat 6acae90f: as wide as Thoughts (the page wrap, 1280px) */.fo{max-width:none}
.fo-mtext.fo-md{white-space:normal;height:12em;max-height:12em;resize:vertical;box-sizing:border-box;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);font-size:calc(14.5px*var(--fz,1));line-height:1.5}.fo-md h3{font-size:1.15em;margin:.8em 0 .3em}.fo-md h4{font-size:1em;margin:.7em 0 .2em}.fo-md p{margin:.4em 0}.fo-md ul,.fo-md ol{margin:.3em 0 .3em 1.3em;padding:0}.fo-md pre{white-space:pre-wrap;background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:8px}.fo-tbl{border-collapse:collapse;margin:.5em 0;font-size:.95em;display:block;overflow-x:auto}.fo-tbl td{border:1px solid var(--line);padding:4px 8px;vertical-align:top}.fo-tbl tr:first-child td{font-weight:700;background:var(--paper)}
.fo-mopen .fo-edhead{display:flex;align-items:center;gap:8px}.fo-edhead .fo-exp{margin-left:auto;font-size:calc(22px*var(--fz,1));line-height:1;min-width:34px;min-height:34px;padding:4px 8px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--ink);cursor:pointer}.fo-edhead .fo-exp:hover{color:var(--ink)}.fo-mopen.fo-big .fo-mtext.fo-md{height:auto;max-height:80vh}
#fo-map .fo-lplus{cursor:pointer}#fo-map .fo-lplus circle{fill:var(--card);stroke:var(--th-focus);stroke-width:2;stroke-dasharray:4 3}#fo-map .fo-lplus text{text-anchor:middle;font:700 20px var(--body);fill:var(--th-focus)}#fo-map .fo-lplus:hover circle,#fo-map .fo-lplus:focus-visible circle{fill:color-mix(in srgb,var(--th-focus) 14%,var(--card));stroke-dasharray:none}
.fo-addpick{position:absolute;top:44px;z-index:8;width:min(320px,90%);background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px;box-shadow:0 10px 30px rgba(0,0,0,.14)}.fo-addpick.r{right:12px}.fo-addpick.l{left:12px}.fo-addpick input{width:100%;box-sizing:border-box;margin:6px 0;padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--paper);color:var(--ink);font:inherit}.fo-apl{display:flex;flex-direction:column;max-height:240px;overflow:auto}.fo-aprow{text-align:left;border:0;background:none;padding:6px 8px;border-radius:6px;color:var(--ink);font:inherit;cursor:pointer}.fo-aprow:hover{background:color-mix(in srgb,var(--th-focus) 12%,var(--card))}
#fo-ed{position:relative}#fo-ed .fo-addpick{top:36px}
.fo-aptags{display:flex;flex-wrap:wrap;gap:4px;margin:6px 0 0}.fo-aptag{border:1px solid var(--line);background:var(--paper);color:var(--indigo,#3b3fb6);border-radius:999px;padding:2px 8px;font:calc(12.5px*var(--fz,1)) var(--body);cursor:pointer}.fo-aptag span{color:var(--soft)}.fo-aptag.on{background:var(--indigo,#3b3fb6);color:#fff;border-color:var(--indigo,#3b3fb6)}.fo-aptag.on span{color:#fff}
/* Focus cards in rows, not CSS columns: column balancing left an empty slot that read as an invisible thought (razie-161, 2026-10-12) */
.fo-list{columns:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px;align-items:start}.fo-list>.th-card{margin:0}
/* Mockup-focus-split: brain left, editor over notes right, fold arrows between (the Journeys mockup's tab 2) */
:root{--fold-hi:#e6b37f;--fold-lo:#9a4b12;--fold-edge:#6e350c}
@media (prefers-color-scheme:dark){:root{--fold-hi:#f2c79a;--fold-lo:#c0712f;--fold-edge:#8a4a17}}
.fo-split{display:grid;grid-template-columns:minmax(0,1.15fr) 28px minmax(0,1fr);gap:0;align-items:start}
.fo-split[data-show=left]{grid-template-columns:minmax(0,1fr) 28px}
.fo-split[data-show=right]{grid-template-columns:28px minmax(0,1fr)}
.fo-split[data-show=left]>.fo-rp,.fo-split[data-show=right]>.fo-lp{display:none}
.fo-lp{position:sticky;top:8px;min-width:0}.fo-rp{min-width:0}
.fo-lp #fo-map canvas{height:max(420px,calc(100vh - 210px))}
.fo-lp .fo-brain.fo-bfolded{position:static}
.fo-rp #fo-cards{margin-top:12px}
.fo-split .fold{display:flex;flex-direction:column;align-items:center;gap:6px;padding-top:44px;width:28px}
.fo-split .fold [data-fold]{width:28px;height:28px;border:0;background:none;padding:0;cursor:pointer;filter:drop-shadow(0 2px 1.5px rgba(0,0,0,.35));transition:transform .12s}
.fo-split .fold [data-fold]:hover{transform:scale(1.15);filter:drop-shadow(0 3px 2px rgba(0,0,0,.4))}
.fo-split .fold [data-fold]:active{transform:scale(.95);filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
.fo-split[data-show=left] .fold [data-fold=r],.fo-split[data-show=right] .fold [data-fold=l]{display:none}
@media (prefers-reduced-motion:reduce){.fo-split .fold [data-fold]{transition:none}}
@media (max-width:899px){.fo-split,.fo-split[data-show]{grid-template-columns:1fr}.fo-split>.fo-lp,.fo-split>.fo-rp{display:block}.fo-split .fold{display:none}.fo-lp{position:static}.fo-lp #fo-map canvas{height:min(62vh,560px)}}
</style></head>
<body>
<div class="wrap"><main class="fo" id="fo-main"><p class="small fo-back"><a href="#" data-go="/thoughts">‹ Thoughts</a></p><p class="small" id="fo-load">Loading the Focus…</p></main></div>
<dialog id="fo-ask"><p id="fo-askt"></p><div id="fo-asksw" class="fo-sw" hidden><span class="on" data-v="one">one task</span><span data-v="split">split by feature</span></div><div class="fo-askb"><button type="button" class="btn sm" id="fo-askno">Cancel</button><button type="button" class="btn sm primary" id="fo-askgo">OK</button></div></dialog>
<script>
// the brain (base Viz:brain, P-1351 changes: { } sides that grow, domain purple, features teal, topics grey, nothing moves on select, a click off every node closes)
const VB=(()=>{
// The brain (SpecUi 2.26, the Thoughts page with ThoughtFlow on): a map of this project — its classes (from its Spec:
// topics) on an inner ring, its largest features (from Spec, SpecUi, Design and Testing) around them, in App:Dashboard's
// spring layout, colours and caption — that follows what is being written: the newest class or feature named lights up
// and the map glides to it, the ones named earlier stay lit as a trail. Lifted from App:Dashboard (`domain()`, `brain()`,
// `d2parse()`), which keeps its own copy for now (an app can't load the page's scripts). One module, no page in it but
// the elements it is handed; the Thoughts page inlines it, and it runs in Node as it is for the tests.
//
// mount(el, {project, parts, load, store}) → {follow(text), listening(on), level(v), collapse(on), state()}
const 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();
/** The features of a project's Spec and SpecUi (a section's behaviour codes, by prefix), largest first: {id, name, beh,
* codes (beh's), topic (where it is first)}. */
function featuresOf(T) {
const feats = new Map(), seen = new Set();
for (const topic of ["Spec", "SpecUi"]) {
let sec = { title: topic };
for (const line of (T[topic] || "").split("\n")) {
const h = HEAD.exec(line); if (h) { sec = { title: title(h[2]) }; 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 (seen.has(code)) continue; seen.add(code);
if (!feats.has(pre)) feats.set(pre, { id: pre, name: sec.title, beh: [], codes: [], topic });
feats.get(pre).beh.push(clean(line.replace(/^\s*[-*]\s+/, "").replace(/\s*\^[\w-]+\s*$/, ""))); feats.get(pre).codes.push(code);
}
}
return [...feats.values()].sort((a, b) => b.beh.length - a.beh.length);
}
/** The classes declared in these Spec topics' texts: {name, fields: [{name, type, many}], group, tip, also}. */
function classesOf(texts) {
const C = [];
for (const t of texts) {
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] === "*" }) }
// its other names: an *Also called:* line after it, before the next heading or class
const after = t.slice(re.lastIndex).split(/\n#{1,6}\s|\$class\s/)[0], a = /\*{0,2}Also called:\*{0,2}\s*([^\n]*)/i.exec(after);
const also = a ? a[1].replace(/\([^)]*\)/g, "").split(/\.(?:\s|$)/)[0].split(/[,;]/).map(x => x.replace(/[`*]/g, "").trim()).filter(Boolean) : [];
C.push({ name: m[1], fields, group: (/@group\("([^"]*)"\)/.exec(m[3]) || [])[1] || "", tip: (/@tooltip\("([^"]*)"\)/.exec(m[3]) || [])[1] || "", also });
}
}
return C;
}
const STOP = new Set(["the", "and", "for", "with", "from", "that", "this", "page", "pages", "your", "their", "into", "over", "when", "what", "each", "every", "more"]);
// a node and the words that name it: a class by its name (and plural, split camel case) and its *Also called* names, a
// feature by its name and id, both by the longer words of their label; a behaviour only by its code written out
const classNode = c => ({ 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(), ...(c.also || []).map(x => x.toLowerCase())], r: 14 });
const featureNode = f => ({ id: "f:" + f.id, kind: "feature", label: f.name, sub: f.beh.length + " behaviours", words: [f.name.toLowerCase(), f.id], r: 8, f });
function keyed(n) { const ks = new Set(n.words); if (n.kind !== "behaviour") 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); return n }
/** The model a text can name, unplaced: classes, every feature, every behaviour (by its code). */
function modelNodes(C, F) {
const N = [...C.map(classNode), ...F.map(featureNode)];
for (const f of F) (f.codes || []).forEach((code, i) => N.push({ id: "b:" + code, kind: "behaviour", label: code.toUpperCase(), words: [code], f, code, beh: f.beh[i] }));
return N.map(keyed);
}
/** The map: nodes (classes, then the 36 largest features) with the words that name them, links, laid out by springs. */
function graph(C, Fall) {
const byName = new Map(C.map(c => [c.name, c])), N = [], E = [];
for (const c of C) N.push(classNode(c));
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 = Fall.slice(0, 36);
for (const f of F) {
const n = featureNode(f); N.push(n);
const text = f.beh.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" }) }
}
for (const n of N) keyed(n);
// classes on an inner ring by group, features on an outer one, 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 }
}
return { N, E, C, F };
}
/**
* What the text names, as a trail: each node named (a whole word or words of its keys: its name, plural, split camel
* case, the longer words of its label), in the order of where it was named last; the last is the newest.
*/
function trailOf(text, N) {
const last = new Map();
for (const h of hitsOf(text, N)) if (!last.has(h.n) || last.get(h.n) < h.i) last.set(h.n, h.i);
return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
}
/** How often the texts name each node, and which texts do: Map node → {count, by: [the texts' indexes]}. */
function mentionsOf(texts, N) {
const out = new Map();
texts.forEach((text, j) => { for (const h of hitsOf(text, N)) { const m = out.get(h.n) || { count: 0, by: [] }; m.count++; if (!m.by.includes(j)) m.by.push(j); out.set(h.n, m) } });
return out;
}
// each place a node is named: whole words, case-insensitive, a trailing s allowed
function hitsOf(text, N) {
const t = String(text).toLowerCase(), hits = [], word = c => /[a-z0-9]/.test(c || " ");
for (const n of N) for (const k of n.keys) for (let i = t.indexOf(k); i >= 0; i = t.indexOf(k, i + 1)) {
if (word(t[i - 1])) continue; const e = i + k.length;
if (!word(t[e])) hits.push({ n, i, len: k.length }); else if (t[e] === "s" && !word(t[e + 1])) hits.push({ n, i, len: k.length + 1 });
}
// where several are named by the same words, the longest name wins, then a class over a feature ("focus view" is
// FocusView, not Focus; "focus" alone is the class Focus, not the feature *Focus pages*); words inside a longer name
// that won are that name's
hits.sort((a, b) => b.len - a.len || (a.n.kind === "class" ? 0 : 1) - (b.n.kind === "class" ? 0 : 1));
const taken = [];
for (const h of hits) if (!taken.some(x => h.i < x.i + x.len && x.i < h.i + h.len)) taken.push(h);
return taken;
}
/** The data, read once a page view and kept for the session: the project's Spec: topics (the first 12 by name), then
* Spec, SpecUi, Design, Testing; `partial` when a read failed twice (shown, never kept). */
async function loadMap(get, store, project, build) {
// kept for the session under the project and the build, for ten minutes (the Spec moves on); a load in which any read
// failed — a 429 from the burst, a timeout — is never kept, so one bad load can't stand for the whole session
const key = "d2.brain." + (project || "") + "." + (build || ""), now = Date.now();
try { const c = store && JSON.parse(store.getItem(key) || "null"); if (c && c.at && now - c.at < 600000 && c.d) return c.d } catch (e) { }
let partial = false;
// a failed read is tried again once; a topic that isn't there (404) is no failure, just empty
const gone = e => /^404\b/.test(String(e && e.message));
const once = async u => { try { return await get(u) } catch (e) { if (gone(e)) return null; try { return await get(u) } catch (x) { if (!gone(x)) partial = true; return null } } };
const text = async n => { const r = await once("/api/v2/topics/" + n + "?format=json"); return (r && r.text) || "" };
// at most four reads at a time, in the order given
const pool = async (xs, f) => { const out = new Array(xs.length); let k = 0; await Promise.all(Array.from({ length: Math.min(4, xs.length) }, async () => { while (k < xs.length) { const i = k++; out[i] = await f(xs[i]) } })); return out };
const list = await once("/api/v2/topics?category=Spec");
if (!list) partial = true;
const names = ((list && list.data) || []).map(t => t.name).sort().slice(0, 12); // sorted first: the same 12 whatever order the list comes in
const C = classesOf(await pool(names, text));
const T = {}, tn = ["Spec", "SpecUi", "Design", "Testing"], tt = await pool(tn, text); tn.forEach((n, i) => { T[n] = tt[i] });
const d = { C, F: featuresOf(T).slice(0, 36).map(f => ({ id: f.id, name: f.name, beh: f.beh.slice(0, 40) })), ...(partial ? { partial: true } : {}) };
if (!partial) try { store && store.setItem(key, JSON.stringify({ at: now, d })) } catch (e) { }
return d;
}
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
/**
* The brain in `el`. `o.parts` are its elements ({canvas, cap, meter}); `o.load()` gives {C, F}; `o.open(n)` opens a
* dot. Without a canvas context (no browser) it keeps its state and caption and draws nothing.
*/
/* P-1351 mockup: two halves of one brain filling the map: the model in the left lobe, the topics in the right; the
Focus's title between them. Placement only: the model's own spring layout is kept, scaled into its lobe. */
/* each side's { } grows with its nodes up to the full space, so density stays the same (razie, chat 6acc3bae) */
const MAXR = { rx: 430, ry: 340 }, GAP = 40, LOBE = { L: { x: -470, y: 0, rx: 430, ry: 340 }, R: { x: 470, y: 0, rx: 430, ry: 340 } };
const sideSize = n => ({ rx: Math.min(MAXR.rx, 150 + 48 * Math.sqrt(n)), ry: Math.min(MAXR.ry, 110 + 40 * Math.sqrt(n)) });
function lobes(M, T) {
const ms = M.N, sl = sideSize(ms.length), sr = sideSize(T.length);
const tall = typeof window !== "undefined" && window.innerWidth < 700; // a phone: the two sides stacked, model on top
if (tall) { LOBE.L = { x: 0, y: -(sl.ry + GAP), ...sl }; LOBE.R = { x: 0, y: sr.ry + GAP, ...sr } }
else { LOBE.L = { x: -(sl.rx + GAP), y: 0, ...sl }; LOBE.R = { x: sr.rx + GAP, y: 0, ...sr } } if (ms.length) { let cx = 0, cy = 0; for (const n of ms) { cx += n.x; cy += n.y } cx /= ms.length; cy /= ms.length;
const q = a => { const v = a.slice().sort((x, y) => x - y); return v[Math.floor(v.length * .85)] || 1 };
const mx = q(ms.map(n => Math.abs(n.x - cx))), my = q(ms.map(n => Math.abs(n.y - cy)));
const sx = Math.max(.2, LOBE.L.rx - 110) / mx, sy = Math.max(.2, LOBE.L.ry - 60) / my;
for (const n of ms) { let dx = (n.x - cx) * sx, dy = (n.y - cy) * sy; const e = Math.hypot(dx / (LOBE.L.rx - 90), dy / (LOBE.L.ry - 40)); if (e > 1) { dx /= e; dy /= e } n.x = LOBE.L.x + dx; n.y = LOBE.L.y + dy } }
T.forEach((t, i) => { const a = (i / Math.max(1, T.length)) * 6.283 - 1.57, r = T.length > 1 ? 120 : 0;
const n = { id: "t:" + t.name, n: t.n, kind: "topic", label: t.title, r: 16, words: [t.title.toLowerCase()], keys: [t.title.toLowerCase()], x: LOBE.R.x + Math.cos(a) * r * 1.3, y: LOBE.R.y + Math.sin(a) * r };
M.N.push(n) });
return M;
}
function mount(el, o = {}) {
const p = o.parts || {}, cv = p.canvas, cap = p.cap, meter = p.meter, W0 = typeof window !== "undefined" ? window : {};
const still = !!(W0.matchMedia && W0.matchMedia("(prefers-reduced-motion: reduce)").matches);
const g = cv && cv.getContext ? cv.getContext("2d") : null, raf = W0.requestAnimationFrame ? f => W0.requestAnimationFrame(f) : null;
let M = null, trail = [], focus = null, text = "", on = false, lvl = 0, folded = false;
let W = 0, H = 0, dpr = 1, cam = { x: 0, y: 0, z: 1 }, to = { x: 0, y: 0, z: 1 }, pulses = [], t0 = 0, drawing = false;
const nb = n => M.E.filter(e => e.a === n || e.b === n).map(e => ({ n: e.a === n ? e.b : e.a, e }));
const setCap = h => { if (cap) cap.innerHTML = h };
function size() { if (!cv) return; dpr = Math.min(2, W0.devicePixelRatio || 1); W = cv.clientWidth || 0; H = cv.clientHeight || 0; cv.width = W * dpr; cv.height = H * dpr }
function fit() { if (!M) return; let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; for (const n of M.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) }
const tall = W < 700; to = tall ? { x: 0, y: (LOBE.L.y - LOBE.L.ry + LOBE.R.y + LOBE.R.ry) / 2, z: Math.min(W / (2 * MAXR.rx + 40), H / (4 * MAXR.ry + 2 * GAP + 60)) || 1 } : { x: (LOBE.L.x - LOBE.L.rx + LOBE.R.x + LOBE.R.rx) / 2, y: 0, z: Math.min(W / (4 * MAXR.rx + 2 * GAP + 40), H / (2 * MAXR.ry + 40)) || 1 } }
function rest() { const C = M.C, F = M.F;
return (C.length ? "<b>" + C.length + " classes</b> and their links" + (F.length ? ", with the " + F.length + " largest features around them" : "") + ". Name one in your thought, or tap a dot."
: F.length ? "<b>" + F.length + " features</b>. This project has no class model yet, so the map shows its features only." : "No classes or features to draw yet: the map fills in from the project's Spec.")
+ (M.partial ? ' <span class="th-bpart">map partial</span>' : "") }
function go(n, pan) {
focus = n; if (!M) return;
if (!n) { fit(); pulses = []; return setCap(rest()) }
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) }
/* P-1351 mockup: no zoom on select; a thought pans gently to what it names, a tapped dot stays in place */ /* the whole brain is on screen: nothing moves, the selection is shown by emphasis */
pulses = still ? [] : ns.slice(0, 12).map(({ n: m }, i) => ({ a: n, b: m, p: -i * .12 }));
setCap(caption(n));
}
// each concept's name is its link (what Open was); folded to a strip, the active one's related concepts follow the
// trail, lighter, as many as the strip has room for
const name = (x, n) => { const t = x === n ? "<b>" + esc(x.label) + "</b>" : esc(x.label); return o.href ? '<a class="th-bn" href="' + esc(o.href(x)) + '">' + t + "</a>" : t };
function caption(n) {
let h = "Following you: " + trail.map(x => name(x, n)).join(" → ");
if (!folded) return h;
let room = (cap && cap.clientWidth ? Math.floor(cap.clientWidth / 7) : 110) - 15 - trail.reduce((a, x) => a + x.label.length + 3, 0);
const rel = [];
for (const x of related(n)) { if (x.label.length + 3 > room) break; room -= x.label.length + 3; rel.push(x) }
return rel.length ? h + ' <span class="th-brel">→ ' + rel.map(x => name(x, null)).join(" · ") + "</span>" : h;
}
/** The concepts related to n, nearest first: its references in the class model, then the features that mention it; none on the trail. */
function related(n) {
const d = m => (m.x - n.x) ** 2 + (m.y - n.y) ** 2, ns = nb(n).filter(x => !trail.includes(x.n));
return [...ns.filter(x => !x.e.soft).sort((a, b) => d(a.n) - d(b.n)), ...ns.filter(x => x.e.soft).sort((a, b) => d(a.n) - d(b.n))].map(x => x.n).filter((m, i, a) => a.indexOf(m) === i);
}
function draw(ts) {
if (!g || !M) { drawing = false; return } drawing = true;
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 css = getComputedStyle(document.documentElement), col = n => css.getPropertyValue(n).trim();
const ink = col("--ink"), line = col("--line"), acc = col("--accent") || "#4a7", attn = col("--attn") || acc, leaf = col("--leaf") || "#6a6", soft = col("--soft");
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); /* razie, chat 6acc3bae: a big { nodes } on each side instead of lobes */ const brace = (x, t, b, d) => { const m = (t + b) / 2, w = 26 * d; g.beginPath(); g.moveTo(x + w, t); g.quadraticCurveTo(x, t, x, t + 40); g.lineTo(x, m - 30); g.quadraticCurveTo(x, m, x - w, m); g.quadraticCurveTo(x, m, x, m + 30); g.lineTo(x, b - 40); g.quadraticCurveTo(x, b, x + w, b); g.stroke() };
g.lineWidth = 5 / Math.max(.75, cam.z); g.lineCap = "round";
for (const [L, c] of [[LOBE.L, "#8e5cc8"], [LOBE.R, "#9a9a9a"]]) { g.strokeStyle = c; g.globalAlpha = .55; brace(L.x - L.rx, L.y - L.ry, L.y + L.ry, 1); brace(L.x + L.rx, L.y - L.ry, L.y + L.ry, -1) } g.globalAlpha = 1;
g.fillStyle = soft; g.textAlign = "center"; g.font = "600 " + (11 / Math.max(.75, cam.z)) + "px system-ui,sans-serif"; g.fillText("MODEL", LOBE.L.x, LOBE.L.y - LOBE.L.ry + 22); g.fillText("TOPICS", LOBE.R.x, LOBE.R.y - LOBE.R.ry + 22);
g.fillStyle = ink; g.font = "700 " + (15 / Math.max(.75, cam.z)) + "px system-ui,sans-serif"; { const gx = (LOBE.L.x + LOBE.L.rx + LOBE.R.x - LOBE.R.rx) / 2, gy = (LOBE.L.y + LOBE.L.ry + LOBE.R.y - LOBE.R.ry) / 2, tl = LOBE.L.y !== 0; g.fillText(String(window.FOTITLE || "").slice(0, 26), tl ? 0 : gx, tl ? gy + 5 : -8) }
const lit = new Set(trail), hot = new Set(focus ? [focus, ...nb(focus).map(x => x.n)] : []), cous = new Set(focus ? [...hot].flatMap(m => nb(m).map(x => x.n)).filter(m => !hot.has(m)) : []);
for (const e of M.E) { const hi = 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 = hi ? acc : line; g.globalAlpha = hi ? .9 : focus ? (hot.has(e.a) && cous.has(e.b) || hot.has(e.b) && cous.has(e.a) ? .55 : .25) : e.soft ? .45 : .9; g.lineWidth = (hi ? 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 q of pulses) { q.p += dt * .225; if (q.p > 1) q.p -= 1.25; if (q.p < 0) continue; g.beginPath(); g.arc(q.a.x + (q.b.x - q.a.x) * q.p, q.a.y + (q.b.y - q.a.y) * q.p, 3 / cam.z, 0, 6.3); g.fillStyle = attn; g.fill() }
for (const n of M.N) { const on1 = hot.has(n) || lit.has(n), on2 = !on1 && cous.has(n), r = n === focus ? n.r + 6 : lit.has(n) ? n.r + 3 : n.r; g.globalAlpha = n.kind === "topic" ? 1 : focus && !on1 ? (on2 ? .75 : .3) : 1; g.beginPath(); g.arc(n.x, n.y, r, 0, 6.3); /* P-1351 mockup: domain purple, features teal (razie's pick); lit ones get a ring */g.fillStyle = n.kind === "class" ? "#8e5cc8" : n.kind === "feature" ? "#1f8a8c" : n.kind === "topic" ? "#9a9a9a" : leaf; g.fill(); if (n === focus || lit.has(n)) { g.lineWidth = 3 / cam.z; g.strokeStyle = acc; g.stroke() }
if (n.kind === "topic" || (focus ? on1 || on2 : (n.kind === "class" || cam.z > 1.5))) { g.font = (n === focus ? "700 " : n.kind === "class" ? "600 " : "400 ") + (24 / Math.max(.75, cam.z) * (n === focus ? 1.15 : on2 ? .9 : 1)) + "px system-ui,sans-serif"; g.fillStyle = n.kind === "class" ? "#8e5cc8" : n.kind === "feature" ? "#1f8a8c" : n.kind === "topic" ? "#8a8a8a" : 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;
if (lvl > 0 && !on) lvl = 0;
if (raf) raf(draw);
}
function follow(t) {
text = String(t ?? ""); if (!M) return;
const was = focus; trail = trailOf(text, M.N);
const n = trail.length ? trail[trail.length - 1] : null;
if (n !== was || n) go(n);
}
const ready = (async () => {
try { const d = await (o.load ? o.load() : { C: [], F: [] }); M = lobes(graph(d.C || [], d.F || []), d.T || []); M.partial = !!d.partial } catch (e) { M = graph([], []); M.partial = true }
size(); go(null); cam = { ...to }; follow(text);
if (cv && W0.ResizeObserver) new W0.ResizeObserver(() => { size(); if (!focus) fit() }).observe(cv);
if (cv && cv.addEventListener) 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 M.N) { const q = (n.x - x) ** 2 + (n.y - y) ** 2; if (q < d && q < (18 / cam.z) ** 2) { d = q; hit = n } }
if (!hit && o.blank) o.blank(); /* P-1351: a click off every node closes what is open */ if (hit && hit === focus && o.open) return o.open(hit); if (hit && !trail.includes(hit)) trail = [...trail, hit]; go(hit, false) });
if (g && raf && !drawing) raf(draw);
})();
return {
ready, follow,
/** while listening: the edge glows */
listening(v) { on = !!v; el.classList && el.classList[on ? "add" : "remove"]("th-listening"); if (!on) this.level(0) },
/** the meter, 0..1 */
level(v) { lvl = Math.max(0, Math.min(1, +v || 0)); if (meter && meter.style) meter.style.transform = "scaleY(" + (on ? Math.max(.08, lvl) : 0).toFixed(2) + ")" },
collapse(v) { folded = !!v; el.classList && el.classList[folded ? "add" : "remove"]("th-bfolded"); if (!folded) { size(); if (!focus) fit() } if (M && focus) setCap(caption(focus)) },
state: () => ({ focus: focus && focus.label, trail: trail.map(n => n.label), caption: cap ? cap.innerHTML : "", listening: on, collapsed: folded, level: lvl, nodes: M ? M.N.length : 0 }),
};
}
return { graph, mount, featuresOf, classesOf };
})();
/* P-1194/P-1195 (razie, chat 6aca1fb6, 2026-10-10): on his iPad every textarea types slowly and a contenteditable block is fast, so the boxes are plain-text editable blocks that answer like a textarea: value, selectionStart/End, setSelectionRange, select, placeholder, rows. Talk (the page's speech input) and iOS dictation, autocorrect, paste, undo all stay. */
window.thTX=el=>{el.classList.add("th-tx");el.setAttribute("role","textbox");el.setAttribute("aria-multiline","true");el.contentEditable="plaintext-only";
if(el.contentEditable!=="plaintext-only"){el.contentEditable="true";el.addEventListener("paste",e=>{e.preventDefault();const t=(e.clipboardData||window.clipboardData).getData("text/plain");document.execCommand("insertText",false,t)})}
const text=()=>{let o="";const walk=(n,top)=>{for(const c of n.childNodes){if(c.nodeType===3)o+=c.data;else if(c.nodeName==="BR"){if(c.nextSibling||!top)o+="\n"}else if(c.nodeType===1){const blk=/^(DIV|P)$/.test(c.nodeName);if(blk&&o&&!o.endsWith("\n"))o+="\n";walk(c,false)}}};walk(el,true);return o};
const offs=()=>{const sel=getSelection();if(!sel.rangeCount||!el.contains(sel.anchorNode))return [text().length,text().length];const at=(node,off)=>{const r=document.createRange();r.selectNodeContents(el);r.setEnd(node,off);const f=r.cloneContents(),d=document.createElement("div");d.appendChild(f);return window.thTX.len(d)};const a=at(sel.anchorNode,sel.anchorOffset),b=at(sel.focusNode,sel.focusOffset);return [Math.min(a,b),Math.max(a,b)]};
const place=(x,y)=>{const pos=k=>{let left=k;const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let t,last=null;while((t=w.nextNode())){last=t;if(left<=t.data.length)return [t,left];left-=t.data.length}return last?[last,last.data.length]:[el,el.childNodes.length]};const r=document.createRange(),p=pos(x),q=pos(y);r.setStart(p[0],p[1]);r.setEnd(q[0],q[1]);const sel=getSelection();sel.removeAllRanges();sel.addRange(r)};
Object.defineProperties(el,{value:{get:text,set(v){el.textContent=String(v==null?"":v)}},
selectionStart:{get:()=>offs()[0],set(v){place(v,v)}},selectionEnd:{get:()=>offs()[1],set(v){place(v,v)}},
placeholder:{get:()=>el.dataset.ph||"",set(v){el.dataset.ph=v}},rows:{get:()=>+(el.dataset.rows||2),set(v){el.dataset.rows=v}}});
el.setSelectionRange=(x,y)=>place(x,y);el.select=()=>place(0,text().length);return el};
window.thTX.len=d=>{let o=0;const walk=n=>{for(const c of n.childNodes){if(c.nodeType===3)o+=c.data.length;else if(c.nodeName==="BR")o+=1;else if(c.nodeType===1)walk(c)}};walk(d);return o};
function inl(s,home){return esc(s).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/(^|[^*\w])\*([^*\s][^*]*)\*/g,"$1<i>$2</i>")
.replace(/\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]/g,(m,a,l)=>{const [n,s]=a.split("#");const h=(n?home+"/topics/"+encodeURIComponent(n.trim()):"")+(s?"#"+encodeURIComponent(s.replace(/^\^/,"")):"");return `<a href="#" data-go="${h}">${l||a}</a>`})
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,(m,l,u)=>`<a href="#" data-go="${/^\//.test(u.replace(/&/g,"&"))?home+u:u}">${l}</a>`)
.replace(/(^|\s)(https?:\/\/[^\s<]+)/g,(m,a,u)=>`${a}<a href="#" data-go="${u}">${u}</a>`).replace(/\s\^([a-z][\w-]*)\s*$/,' <span class="code">^$1</span>')}
function md(src,home=""){const L=String(src).replace(/\r\n/g,"\n").split("\n"),o=[];let list=null,code=null,para=[];
const fp=()=>{if(para.length){o.push("<p>"+para.map(x=>inl(x,home)).join("<br>")+"</p>");para=[]}},fl=()=>{if(list){o.push(`</${list}>`);list=null}};
for(const l of L){if(code!==null){if(/^\s*```/.test(l)){o.push("<pre><code>"+esc(code)+"</code></pre>");code=null}else code+=(code?"\n":"")+l;continue}
if(/^\s*```/.test(l)){fp();fl();code="";continue}let m;
if(/^\s*\|.*\|\s*$/.test(l)){fp();fl();const cells=l.trim().replace(/^\||\|$/g,"").split("|").map(c=>c.trim());if(cells.every(c=>/^:?-{2,}:?$/.test(c)))continue;const last=o[o.length-1];const row="<tr>"+cells.map(c=>"<td>"+inl(c,home)+"</td>").join("")+"</tr>";if(last&&last.startsWith("<table")&&!last.endsWith("</table>"))o[o.length-1]=last+row;else o.push('<table class="fo-tbl">'+row);continue}
if(o.length&&o[o.length-1].startsWith("<table")&&!o[o.length-1].endsWith("</table>"))o[o.length-1]+="</table>";
if((m=/^(#{1,6})\s+(.*?)\s*(\{#[\w-]+\})?$/.exec(l))){fp();fl();o.push(`<h${m[1].length<3?3:4}>${inl(m[2],home)}</h${m[1].length<3?3:4}>`);continue}
if((m=/^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(l))){fp();const k=/\d/.test(m[1])?"ol":"ul";if(list!==k){fl();o.push(`<${k}>`);list=k}o.push("<li>"+inl(m[2],home)+"</li>");continue}
if((m=/^\s*>\s?(.*)$/.exec(l))){fp();fl();o.push("<blockquote>"+inl(m[1],home)+"</blockquote>");continue}
if(!l.trim()){fp();fl();continue}fl();para.push(l)}
if(code!==null)o.push("<pre><code>"+esc(code)+"</code></pre>");fp();fl();if(o.length&&o[o.length-1].startsWith("<table")&&!o[o.length-1].endsWith("</table>"))o[o.length-1]+="</table>";return o.join("")}
// Page:Focus (P-1146; razie 2026-10-10 "app-focus publish", "go from the last working mockup"): the locked mockup
// App:Mockup-focus-v1 v19, drawn live from GET /api/v2/thoughts/<name>/focus. The map's layout is the built-in Focus
// view's (focuspage.ts, removed in P-1149); the boxes are thTX editable blocks (P-1194: textareas type slowly on the iPad).
const D2=window.d2||{},PARAMS=Object.assign({},window.d2params||{},D2.params||{});
const $=id=>document.getElementById(id),esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
const enc=n=>n.split(":").map(encodeURIComponent).join(":"),pq=p=>"?project="+encodeURIComponent(p);
const cut=(s,n)=>(s=String(s??"")).length>n?s.slice(0,n-1)+"…":s;
const api=(m,u,b)=>D2.api?Promise.resolve(D2.api(m,u,b)):Promise.reject(new Error("E_BRIDGE: this page runs inside d2 (no d2.api here)"));
const why=e=>String(e&&e.message||e).replace(/^E_[A-Z_]+:\s*/,"");
const go=u=>{if(typeof D2.go==="function")return Promise.resolve(D2.go(u)).catch(()=>{});location.href=u};
const PROJ=String(PARAMS.project||""),NAME="Thought:"+String(PARAMS.name||"").replace(/^Thought:/,""),KEY=PROJ+"/"+NAME;
const fpath=op=>"/api/v2/thoughts/"+enc(NAME)+"/focus"+(op?"/"+op:"")+pq(PROJ);
const tpath=(m,op)=>"/api/v2/thoughts/"+enc(m.name)+(op?"/"+op:"")+pq(m.project||PROJ);
const typing=e=>{const x=e.target;if(x&&(x.tagName==="TEXTAREA"||x.tagName==="INPUT"||x.isContentEditable))window.d2typingAt=performance.now()};
document.addEventListener("keydown",typing,true);document.addEventListener("input",typing,true);
let V=null,ed={k:"notes"},view={k:"notes"},stamp="",dirty=false,timer=0,openCard=null,busy=false;
const say=(t,ok)=>{const m=$("fo-msg");if(m){m.textContent=t;m.className="small"+(ok?"":" bad")}};
const thoughts=()=>V.members.filter(m=>m.kind==="thought"&&!m.missing),topics=()=>V.members.filter(m=>m.kind==="topic");
const label=m=>m.title||(m.section?m.name+" › "+m.section:m.name.replace(/^Thought:/,""));
const firstLine=t=>(String(t||"").split("\n").find(l=>l.trim())||"").replace(/^#+\s*/,"");
// ---- the map's layout (focuspage.ts focusLayout, unchanged) ----
const CORE={x:360,y:200},LOBES={model:{x:195,y:200},topic:{x:525,y:200}},LOBE_R={rx:165,ry:150},OX=116,OY=140,MAX_DRAWN=12,MAX_MODEL=24;
const OVAL=(()=>{const n=400,ang=[],len=[0];for(let j=0;j<=n;j++)ang.push(-18+j*228/n);
for(let j=1;j<=n;j++){const p=ang[j-1]*Math.PI/180,q=ang[j]*Math.PI/180;len.push(len[j-1]+Math.hypot(OX*(Math.sin(q)-Math.sin(p)),OY*(Math.cos(q)-Math.cos(p))))}return {ang,len}})();
function evenOnOval(t){const want=t*OVAL.len[OVAL.len.length-1];let j=1;while(j<OVAL.len.length-1&&OVAL.len[j]<want)j++;
const f=(want-OVAL.len[j-1])/((OVAL.len[j]-OVAL.len[j-1])||1);return OVAL.ang[j-1]+f*(OVAL.ang[j]-OVAL.ang[j-1])}
function layout(model,tops){const one=(k,side)=>{const c=side<0?LOBES.model:LOBES.topic,out=[];
for(let i=0;i<k;i++){const many=k>6,deg=k===1?90:many?evenOnOval(i/(k-1)):-12+i*215/(k-1),a=deg*Math.PI/180;
const x=Math.round(c.x+side*(many?OX:98)*Math.sin(a)),y=Math.round(c.y-(many?OY:98)*Math.cos(a));
if(!many)out.push({x,y,above:y<400/3});else if(Math.abs(Math.sin(a))>.72)out.push({x,y,above:false,side:true});else out.push({x,y,above:Math.cos(a)>0?i%2===0:i%2===1})}return out};
return {model:one(model,-1),topic:one(tops,1)}}
function coreLines(t){const w=t.split(/\s+/).filter(Boolean);if(t.length<=10||w.length<2)return [cut(t,10)];
let a=w[0],i=1;while(i<w.length&&(a+" "+w[i]).length<=10)a+=" "+w[i++];return [cut(a,10),cut(w.slice(i).join(" "),10)]}
// ---- A | B (razie, chat 6acc9a17, 2026-10-12, P-1355): A = the map as it was before the brain (P-1351), B = the brain; remembered in this browser ----
let MODE=(()=>{try{return localStorage.getItem("fo-view")==="A"?"A":"B"}catch(e){return "B"}})();
function mapSvg(){return MODE==="A"?mapSvgA():mapSvgB()}
document.addEventListener("click",e=>{const b=e.target.closest&&e.target.closest("[data-ab]");if(!b||!V)return;MODE=b.dataset.ab==="A"?"A":"B";try{localStorage.setItem("fo-view",MODE)}catch(x){}render()});
function mapSvgA(){
const tp=topics().slice(0,MAX_DRAWN),model=(V.model||[]).slice(0,MAX_MODEL),rel=(V.related||[]).slice(0,Math.max(0,MAX_DRAWN-tp.length)).slice(0,4);
const L=layout(model.length,tp.length),pos=new Map();tp.forEach((m,i)=>pos.set(m.n,L.topic[i]));
const selN=view.k==="topic"?view.n:view.k==="notes"?0:-1,many=tp.length>6;
const spokes=tp.map(m=>{const p=pos.get(m.n);return `<path class="fo-spoke${m.n===selN?" on":""}" data-n="${m.n}" d="M${CORE.x} ${CORE.y} L${p.x} ${p.y}"/>`}).join("");
const links=(V.links||[]).filter(([a,b])=>pos.has(a)&&pos.has(b)).map(([a,b])=>{const p=pos.get(a),q=pos.get(b),mx=Math.round((p.x+q.x)/2+(p.y-q.y)*.18),my=Math.round((p.y+q.y)/2+(q.x-p.x)*.18);
return `<path class="fo-link" data-a="${a}" data-b="${b}" d="M${p.x} ${p.y} Q${mx} ${my} ${q.x} ${q.y}"/>`}).join("");
const node=m=>{const p=pos.get(m.n),sel=m.n===selN,name=cut(label(m),many?13:22)+(m.project!==V.project?" · "+m.project:""),s=sel?34:30;
const ty=p.side?p.y+4:p.above?p.y-26:p.y+38,tx=p.side?p.x+24:p.x;
return `<g class="fo-node${sel?" sel":""}${m.missing?" gone":""}" data-n="${m.n}" data-kind="topic" role="button" tabindex="0" aria-label="Topic ${topics().indexOf(m)+1}: ${esc(label(m))}"><rect class="fo-shape" x="${p.x-s/2}" y="${p.y-s/2}" width="${s}" height="${s}" rx="7"/><text class="fo-num" x="${p.x}" y="${p.y+4}">${topics().indexOf(m)+1}</text><text class="fo-name${many?" sm":""}" x="${tx}" y="${ty}"${p.side?' style="text-anchor:start"':""}>${esc(name)}</text></g>`};
const RS=[[402,128],[600,352],[420,300],[655,64]],relNode=(r,i)=>{const p={x:RS[i][0],y:RS[i][1]};return `<g class="fo-relg${view.k==="rel"&&view.i===i?" on":""}" data-rel="${i}" role="button" tabindex="0" aria-label="Related topic: ${esc(r.name)}"><circle cx="${p.x}" cy="${p.y}" r="9"/><text x="${p.x+14}" y="${p.y+4}">${esc(cut(r.name,18))} · + Add</text></g>`};
const mi=model.length>6;
const mSpokes=model.map((x,i)=>`<path class="fo-mspoke" data-m="${i}" d="M${CORE.x} ${CORE.y} L${L.model[i].x} ${L.model[i].y}"/>`).join("");
const mLinks=model.flatMap((x,i)=>x.kind==="concept"?[]:(x.topics||[]).filter(n=>pos.has(n)).map(n=>{const p=L.model[i],q=pos.get(n);return `<path class="fo-mlink" data-m="${i}" data-b="${n}" d="M${p.x} ${p.y} Q${CORE.x} ${Math.round((p.y+q.y)/2+(p.y<CORE.y?-40:40))} ${q.x} ${q.y}"/>`})).join("");
const mnode=(x,i)=>{const p=L.model[i],ty=p.side?p.y+4:p.above?p.y-18:p.y+26,tx=p.side?p.x-16:p.x;
return `<g class="fo-mnode${view.k==="model"&&view.i===i?" sel":""}" data-m="${i}" data-mk="${esc(x.kind)}" role="button" tabindex="0" aria-label="${esc(x.kind)}: ${esc(x.label)}"><circle class="fo-mshape" cx="${p.x}" cy="${p.y}" r="10"/><text class="fo-name${mi?" sm":""}" x="${tx}" y="${ty}"${p.side?' style="text-anchor:end"':""}>${esc(cut(x.label,mi?13:22))}</text></g>`};
const capY=model.length>6||tp.length>6?16:34,cur=selN===0?CORE:pos.get(selN);
const ring=cur?`<circle id="fo-ring" cx="${cur.x}" cy="${cur.y}" r="${selN===0?46:27}"/>`:"";
const lines=coreLines(V.title);
return `<svg id="fo-map" viewBox="0 0 720 400" role="group" aria-label="The Focus and its members">
<ellipse class="fo-lobe" cx="${LOBES.model.x}" cy="${LOBES.model.y}" rx="${LOBE_R.rx}" ry="${LOBE_R.ry}"/><ellipse class="fo-lobe" cx="${LOBES.topic.x}" cy="${LOBES.topic.y}" rx="${LOBE_R.rx}" ry="${LOBE_R.ry}"/>
<text class="fo-cap" x="${LOBES.model.x}" y="${capY}">MODEL</text><text class="fo-cap" x="${LOBES.topic.x}" y="${capY}">TOPICS</text>
${mSpokes}${spokes}${links}${mLinks}
<g class="fo-core${selN===0?" sel":""}" data-n="0" role="button" tabindex="0" aria-label="Focus notes"><circle cx="${CORE.x}" cy="${CORE.y}" r="38"/>${lines.map((l,i)=>`<text x="${CORE.x}" y="${CORE.y+4+(i-(lines.length-1)/2)*16}">${esc(l)}</text>`).join("")}</g>
${model.map(mnode).join("")}${tp.map(node).join("")}<g class="fo-relgs">${rel.map(relNode).join("")}</g>
${V.sent?"":`<g class="fo-lplus" data-plus="model" role="button" tabindex="0" aria-label="Add a class to the model"><circle cx="${LOBES.model.x-LOBE_R.rx+26}" cy="${LOBES.model.y}" r="15"/><text x="${LOBES.model.x-LOBE_R.rx+26}" y="${LOBES.model.y+6}">+</text></g><g class="fo-lplus" data-plus="topic" role="button" tabindex="0" aria-label="Add a topic"><circle cx="${LOBES.topic.x+LOBE_R.rx-26}" cy="${LOBES.topic.y}" r="15"/><text x="${LOBES.topic.x+LOBE_R.rx-26}" y="${LOBES.topic.y+6}">+</text></g>`}${ring}</svg>`}
function mapSvgB(){return `<div id="fo-map" class="fo-bmap" role="group" aria-label="The Focus: its model and its members">${V.sent?"":`<div class="fo-btools"><button type="button" class="fo-bplus dom" data-plus="model" title="Add a domain element">+ Domain</button><button type="button" class="fo-bplus fea" data-plus="feature" title="Add a feature">+ Feature</button><button type="button" class="fo-bplus top" data-plus="topic" title="Add a topic">+ Topic</button><span class="fo-blegend">tap a dot twice to open it</span></div>`}<canvas id="fo-cv"></canvas></div>`}
// the brain's data: classes from /api/v2/brain, features from the Focus's own model text (its spec lines and codes), members as topics
let BR=null,FEATS=null;const brainAll=()=>api("GET","/api/v2/brain").catch(()=>({C:[],F:[]}));
const featAll=()=>FEATS||(FEATS=Promise.all(["Spec","SpecUi"].map(t=>api("GET","/api/v2/topics/"+t+"?format=raw").then(j=>typeof j==="string"?j:(j&&(j.text||j.body))||"").catch(()=>""))).then(([a,b])=>VB.featuresOf({Spec:a,SpecUi:b})).catch(()=>[]));
function mountBrain(){if(MODE!=="B"){BR=null;return}const cv=$("fo-cv");if(!cv||!V)return;window.FOTITLE=V.title||"";const model=V.model||[];
BR=VB.mount($("fo-map"),{parts:{canvas:cv},load:async()=>{const d=await brainAll(),lab=x=>String(x.label||"").toLowerCase();
const C=(d.C||[]).filter(c=>model.some(x=>x.kind!=="feature"&&lab(x)===c.name.toLowerCase()));
const F=model.filter(x=>x.kind==="feature").map(x=>{const f=VB.featuresOf({Spec:String(x.text||"")})[0]||{beh:[],codes:[]};return {id:String(x.id).replace(/^f:/,""),name:x.label,beh:f.beh||[],codes:f.codes||[]}});
return {C,F,T:[...topics().map(m=>({name:"m"+m.n,title:cut(label(m),24),n:m.n})),...(V.related||[]).slice(0,6).map((r,i)=>({name:"r"+i,title:cut(r.name,20)+" · + Add",n:-1-i}))]}},
open:n=>{if(n.kind==="topic")return n.n<0?show({k:"rel",i:-1-n.n}):show({k:"topic",n:n.n});const i=model.findIndex(x=>n.kind==="feature"?x.id===n.id:x.kind!=="feature"&&String(x.label).toLowerCase()===String(n.label).toLowerCase());if(i>=0)show({k:"model",i})},
blank:()=>{if(view.k!=="notes")show({k:"notes"})}});
const tx=$("fo-text");BR.ready.then(()=>{if(tx&&tx.value)BR.follow(tx.value)})}
document.addEventListener("pointerdown",e=>{const t=e.target;const ap=$("fo-addpick");if(ap&&!ap.contains(t)&&!(t.closest&&t.closest("[data-plus]")))ap.remove();const sp=$("fo-sendpick");if(sp&&!sp.contains(t)&&!(t.closest&&t.closest("#fo-edsend")))sp.remove()},true);
document.addEventListener("keydown",e=>{if(e.key!=="Escape")return;const ap=$("fo-addpick");if(ap)ap.remove();const sp=$("fo-sendpick");if(sp)sp.remove()});
function plusPick(kind){const old=$("fo-addpick");if(old){const same=old.dataset.kind===kind;old.remove();if(same)return}const br=kind==="thought"?$("fo-ed"):$("fo-brain");if(!br)return;
const w=document.createElement("div");w.id="fo-addpick";w.dataset.kind=kind;w.className="fo-addpick "+(kind==="topic"||kind==="thought"?"r":"l");
w.innerHTML=`<div class="fo-kind">${kind==="topic"?"ADD A TOPIC":kind==="thought"?"ADD ONE OF YOUR THOUGHTS":kind==="feature"?"ADD A FEATURE":"ADD A CLASS TO THE MODEL"}</div>${kind==="thought"?`<div class="fo-aptags" id="fo-aptags"></div>`:""}<input type="search" id="fo-apq" placeholder="${kind==="topic"?"A topic: Spec, Design, a name…":kind==="thought"?"Words from the thought…":kind==="feature"?"A feature: Chats, TurboChat…":"A class: Project, Topic…"}" autocomplete="off"><div id="fo-apl" class="fo-apl"></div><p class="small" id="fo-apsay"></p>`;
br.append(w);const q=$("fo-apq"),l=$("fo-apl");let tm=0,seq=0,tagOn="";
const tagsBar=c=>{const tb=$("fo-aptags");if(!tb||!c)return;const ts=Object.entries(c.tags||{}).filter(([g])=>g!=="focus"&&g!=="archive").sort((a,b)=>b[1]-a[1]).slice(0,10);tb.innerHTML=ts.map(([g,n])=>`<button type="button" class="fo-aptag${g===tagOn?" on":""}" data-tag="${esc(g)}">#${esc(g)} <span>${n}</span></button>`).join("")};
w.addEventListener("click",e=>{const tg=e.target.closest("[data-tag]");if(!tg)return;e.stopPropagation();tagOn=tagOn===tg.dataset.tag?"":tg.dataset.tag;run()});
const show=rows=>{l.innerHTML=rows.length?rows.slice(0,12).map(r=>typeof r==="string"?`<button type="button" class="fo-aprow" data-pick="${esc(r)}">${esc(r)}</button>`:`<button type="button" class="fo-aprow" data-pick="${esc(r.v)}" data-proj="${esc(r.p)}">${esc(r.l)}</button>`).join(""):`<p class="small">Nothing matches.</p>`};
const run=async()=>{const v=q.value.trim(),me=++seq;if(kind==="thought"){try{const j=await api("GET","/api/v2/thoughts?q="+encodeURIComponent(v)+"&project="+encodeURIComponent(PROJ)+(tagOn?"&tag="+encodeURIComponent(tagOn):""));if(me!==seq)return;tagsBar(j.counts);const have=new Set(V.members.map(m=>m.name));show((j.data||[]).filter(x=>x.name!==NAME&&x.name!=="Thought:"+String(NAME).replace(/^Thought:/,"")&&!have.has(x.name)&&!(x.tags||[]).includes("focus")).map(x=>({v:x.name,p:x.project||PROJ,l:firstLine(x.text)||x.name})))}catch(e){$("fo-apsay").textContent=why(e)}return}
if(kind==="topic"){if(!v){l.innerHTML="";return}try{const j=await api("GET","/api/v2/search?q="+encodeURIComponent(v));/* /api/v2/topics ignores q; search ranks names first */if(me!==seq)return;const have=new Set(topics().map(m=>m.name));show((j.data||[]).map(x=>x.name).filter(n=>!have.has(n)))}catch(e){$("fo-apsay").textContent=why(e)}}
else if(kind==="feature"){const Fs=await featAll();if(me!==seq)return;const lo=v.toLowerCase(),have=new Set((V.model||[]).map(x=>String(x.label).toLowerCase()));show(Fs.map(f=>f.name).filter((n,k,a)=>a.indexOf(n)===k&&!have.has(n.toLowerCase())&&(!lo||n.toLowerCase().includes(lo))).slice(0,40))}
else{const C=await brain();if(me!==seq)return;const lo=v.toLowerCase(),have=new Set((V.model||[]).map(x=>String(x.label).toLowerCase()));show(C.map(c=>c.name).filter(n=>!have.has(n.toLowerCase())&&(!lo||n.toLowerCase().includes(lo))).sort())}};
q.addEventListener("input",()=>{clearTimeout(tm);tm=setTimeout(run,180)});if(kind!=="topic")run();setTimeout(()=>q.focus(),0);
l.addEventListener("click",async e=>{const b=e.target.closest("[data-pick]");if(!b)return;e.stopPropagation();const name=b.dataset.pick;
try{if(kind==="thought"){await api("POST",fpath("add"),{members:[{project:b.dataset.proj||PROJ,name}]});say("Thought added to this Focus.",true)}
else if(kind==="topic"){await api("POST",fpath("add"),{members:[{project:PROJ,name}]});say(name+" added to this Focus.",true)}
else{if(dirty)await saveNotes();ed={k:"notes"};view={k:"notes"};render();const tx=$("fo-text");if(!tx)return;tx.value=(String(tx.value||"").trimEnd()+"\n"+name).trim();dirty=true;await saveNotes();say(name+" added to the notes, so it is in the model.",true)}
w.remove();await load()}catch(er){$("fo-apsay").textContent=(kind==="topic"?"Not added: ":"Not saved: ")+why(er)}})}
// ---- the caption line under the map: what you clicked or typed (folded, the map is only this line, sticky) ----
const capHome=()=>`<b>${esc(V.title)}</b>`+(V.model||[]).slice(0,6).map((x,i)=>` · <a href="#" class="th-bn th-brel" data-m="${i}">${esc(cut(x.label,30))}</a>`).join("");
const capModel=i=>{const x=V.model[i],ts=(x.topics||[]).map(n=>V.members.find(m=>m.n===n)).filter(Boolean);return `<a href="#" class="th-bn" data-m="${i}"><b>${esc(x.label)}</b></a> · ${x.kind==="concept"?"class":esc(x.kind)}`+(ts.length?" · in "+ts.map(m=>`<a href="#" class="th-bn th-brel" data-n="${m.n}">${esc(label(m))}</a>`).join(" · "):"")};
const capTopic=n=>{const m=V.members.find(x=>x.n===n),ks=(V.model||[]).map((x,i)=>[x,i]).filter(([x])=>(x.topics||[]).includes(n));return `<a href="#" class="th-bn" data-n="${n}"><b>${esc(label(m))}</b></a>`+(ks.length?" · names "+ks.map(([x,i])=>`<a href="#" class="th-bn th-brel" data-m="${i}">${esc(cut(x.label,30))}</a>`).join(" · "):"")};
const cap=h=>{const c=$("fo-bcap");if(c)c.innerHTML=h};
function follow(t){if(!V)return;t=String(t).slice(-160);let best=null,at=-1;
(V.model||[]).forEach((x,i)=>{const p=t.toLowerCase().lastIndexOf(x.label.toLowerCase());if(p>at){at=p;best=()=>cap(capModel(i))}});
topics().forEach(m=>{const p=t.lastIndexOf(label(m));if(p>at){at=p;best=()=>cap(capTopic(m.n))}});if(best)best()}
// ---- the panel under the map: the notes, a topic, a model node, a related topic ----
const TOOLS=[["b","B","Bold"],["i","I","Italic"],["h","H","Heading"],["ul","•","Bulleted list"],["ol","1.","Numbered list"],["link","[[ ]]","Link a topic"],["tag","#","Tag"]];
const tools=f=>`<div class="fo-tools" role="toolbar" aria-label="Format" data-for="${f}">${TOOLS.map(([k,t,l])=>`<button type="button" class="ibtn fo-tool" data-md="${k}" title="${l}" aria-label="${l}">${esc(t)}</button>`).join("")}</div>`;
const walkN=()=>[0,...topics().map(m=>m.n)];
function pnav(){const W=walkN();if(W.length<2)return "";const cur=view.k==="topic"?view.n:0,i=W.indexOf(cur),prev=W[(i-1+W.length)%W.length],next=W[(i+1)%W.length];
return `<div class="fo-pnav"><a class="btn sm" href="#" data-n="${prev}" role="button" aria-label="Previous">‹</a><span>${cur?`${topics().findIndex(m=>m.n===cur)+1} of ${W.length-1}`:"Focus notes"}</span><a class="btn sm" href="#" data-n="${next}" role="button" aria-label="Next">›</a></div>`}
function nav(){const W=walkN();if(W.length<2)return "";const cur=view.k==="topic"?view.n:0,i=Math.max(0,W.indexOf(cur)),prev=W[(i-1+W.length)%W.length],next=W[(i+1)%W.length];
return `<div class="fo-nav"><a class="btn sm" id="fo-prev" href="#" data-n="${prev}" role="button" aria-label="Previous">‹ Previous</a><span id="fo-count">${cur?`${topics().findIndex(m=>m.n===cur)+1} of ${W.length-1}`:"Focus notes"}</span><a class="btn sm" id="fo-next" href="#" data-n="${next}" role="button" aria-label="Next">Next ›</a></div>`}
function panel(){
if(view.k==="topic"){const m=V.members.find(x=>x.n===view.n);if(m)return `<section class="fo-mopen fo-tp" data-mk="topic"><div class="fo-edhead"><span class="fo-kind">TOPIC · ${esc(label(m))}</span>${m.project!==V.project?`<span class="small">on ${esc(m.project)}</span>`:""}</div>${m.missing?`<p class="fo-gonemsg">This topic isn't there any more, or isn't yours to read.</p>`:`<div class="fo-mtext fo-md" id="fo-tptext" data-name="${esc(m.name)}" data-project="${esc(m.project||"")}">${md(m.text||"")}</div>`}<div class="fo-acts"><a class="btn sm" href="#" data-go="/topics/${encodeURIComponent(m.name)}${m.section?"#"+encodeURIComponent(m.section):""}">Open full</a> <button type="button" class="btn sm fo-tpback">Back to the notes</button>${pnav()}</div></section>`}
if(view.k==="model"){const x=(V.model||[])[view.i];if(x)return `<section class="fo-mopen" data-mk="${esc(x.kind)}"><div class="fo-edhead"><span class="fo-kind">${esc(String(x.title||x.label).toUpperCase())}</span><span class="small">named ${x.count===1?"once":(x.count||0)+" times"}</span></div><div class="fo-mtext">${esc(cut(x.text||x.label,2400))}</div><div class="fo-acts">${x.href?`<a class="btn sm" href="#" data-go="${esc(x.href)}">Open full</a> `:""}<button type="button" class="btn sm fo-tpback">Back to the notes</button></div></section>`}
if(view.k==="rel"){const r=(V.related||[])[view.i];if(r)return `<section class="fo-mopen fo-rp"><div class="fo-edhead"><span class="fo-kind">RELATED TOPIC · ${esc(r.name)}</span><span class="small">not in this focus yet</span></div><div class="fo-mtext fo-md" id="fo-rptext">…</div><div class="fo-acts">${V.sent?"":`<button type="button" class="btn sm primary fo-rpadd" data-name="${esc(r.name)}">+ Add to focus</button> `}<a class="btn sm" href="#" data-go="/topics/${encodeURIComponent(r.name)}">Open full</a> <button type="button" class="btn sm fo-tpback">Back to the notes</button></div></section>`}
view={k:"notes"};const ro=V.sent;
const em=ed.k==="thought"?memberOf(ed.n):null;if(ed.k==="thought"&&!em)ed={k:"notes"};
const ek=ed.k==="thought"?"THOUGHT · "+esc(cut(firstLine(em.text)||label(em),60)):ed.k==="new"?"NEW THOUGHT · in this Focus":"FOCUS · your notes";
return `<section id="fo-ed" class="${ed.k==="notes"?"fo-edfoc":""}" data-kind="${ed.k==="notes"?"focus":"thought"}"><div class="fo-edhead"><span class="fo-kind">${ek}</span><span class="fo-saved" id="fo-saved" aria-live="polite"></span>${ro||ed.k==="new"?"":`<span class="fo-bar"><button type="button" class="btn sm primary" data-act="new">+ New thought</button> <button type="button" class="btn sm" data-plus="thought">Find a thought</button> <a href="#" class="small" data-go="/search?focus=${encodeURIComponent(KEY)}">Search everything to add →</a></span>`}</div>
${ro?"":tools("fo-text")}<div id="fo-text" data-ph="${ed.k==="new"?"A thought on this subject…":ed.k==="thought"?"This thought":"What this Focus is about"}" aria-label="${ed.k==="notes"?"Focus notes":"Thought"}"></div><div class="fo-acts">${ed.k==="thought"?`<button type="button" class="btn sm primary" id="fo-edsave">Save</button><button type="button" class="btn sm" id="fo-edsend">Send ▾</button>${V.sent?"":`<button type="button" class="btn sm" id="fo-edout">Take out of focus</button><button type="button" class="btn sm" id="fo-edarch">Archive</button><button type="button" class="btn sm fo-danger" id="fo-eddel">Delete…</button>`}`:ed.k==="new"?`<button type="button" class="btn sm primary" id="fo-newsave">Save</button><button type="button" class="btn sm" id="fo-edback">Cancel</button>`:ro?"":`<button type="button" class="btn sm primary" id="fo-notesave">Save</button>${V.sent?`<span class="fo-sentb">✓ Sent</span>`:`<button type="button" class="btn sm" id="fo-send"${V.members.length?"":" disabled"} title="Send this Focus to your designer">Send → @designer</button>`}<button type="button" class="btn sm" id="fo-archfoc" title="Archive this Focus and all its thoughts">Archive focus</button><button type="button" class="btn sm" id="fo-dissolve" title="Every thought goes back to Thoughts; your notes go into the first one">${thoughts().length===1&&!topics().length?"Back to a thought":"Dissolve focus"}</button><span class="small fo-privl">This thought defines the Focus: it can't be archived or deleted. Dissolve focus ends it.</span>`}${pnav()}</div></section>`}
// ---- the cards: its thoughts; tick to take out; open one to edit it, Save or Thread now ----
const TH=(()=>{const ANS_RE=/^##\s+(Done|Questions)\b/m,split=text=>{const s=String(text).replace(/\r\n/g,"\n"),m=ANS_RE.exec(s);return m?{orig:s.slice(0,m.index),ans:s.slice(m.index)}:{orig:s,ans:""}};
const MK="**↳ Your answer:**";
const TAGS=/(^|[\s(,;])#[A-Za-z0-9][A-Za-z0-9-]*/g;
const plain=s=>s.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+(\[[ xX]\]\s+)?|\d+[.)]\s+)/,"").replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g,"$2").replace(/\[\[([^\]]+)\]\]/g,"$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g,"$1").replace(/(\*\*|__|\*|_|~~|`)(?=\S)([^]*?\S)\1/g,"$2").replace(/`/g,"").trim();
const parts=text=>{const ls=String(text).replace(/\r\n/g,"\n").split("\n").filter(l=>l.trim()&&!/^\s*(```|---\s*$)/.test(l)),f=plain(ls[0]||""),title=f.replace(TAGS,"$1").replace(/\s+/g," ").trim()||f;
return {title,preview:ls.slice(1,14).map(l=>plain(l).replace(TAGS,"$1").replace(/\s+/g," ").trim()).filter(Boolean).join("\n").slice(0,700)}};
const ansText=a=>a.split("\n").filter(l=>l.trim()&&l.trim()!==MK.trim()&&!/^\s*(```|---\s*$)/.test(l)).map(l=>plain(l.replace(/^#+\s*/,"")).replace(TAGS,"$1").replace(/\s+/g," ").trim()).filter(Boolean).join("\n").slice(0,700);
const p2=n=>String(n).padStart(2,"0"),day=s=>{const d=new Date(s);return isNaN(d)?"":d.getFullYear()+"-"+p2(d.getMonth()+1)+"-"+p2(d.getDate())};
return {split,parts,ansText,day,MK}})();
const RL=["designer","dispatcher","coder","tester","guardian","monitor"],RC={designer:"#8e5cc8",coder:"#2f9a6f",tester:"#3a78c2",dispatcher:"#2a8a8f",monitor:"#555c66",guardian:"#a0662a"};
const atOf=t=>{const m=/(^|[\s(,;])@([A-Za-z][A-Za-z0-9-]*)/.exec(String(t||"").replace(/```[\s\S]*?```/g," "));return m?m[2].toLowerCase().replace(/-\d+$/,""):""};
const FLOWT=new Set(["thread","done","waiting","ai-question","monitor","sent","working"]);
const tagsOf=t=>[...new Set((String(t||"").match(/(^|\s)#([a-z][\w-]*)/gi)||[]).map(x=>x.trim().slice(1).toLowerCase()))].filter(g=>g!=="focus"&&g!=="archive");
const tagLinks=t=>tagsOf(t).map(g=>`<a class="th-tl" href="#" data-go="/thoughts?tag=${encodeURIComponent(g)}">#${esc(g)}</a>`).join("");
const prevOf=t=>String(t||"").split("\n").slice(1).join("\n").replace(/^## In this focus[\s\S]*$/m,"").trim();
function cards(){const ts=thoughts();const nb=String(V.notes||"").trim();
return `<section id="fo-cards"><h2>Thoughts <span class="n">${ts.length+1}</span></h2><div class="th-list fo-list"><article class="th-card foc fo-fcard${ed.k==="notes"?" fo-open":""}" data-focus="1"><span class="th-fmark">FOCUS · ${V.members.length} · defines this focus</span><h3 class="th-title">${esc(cut(V.title,140))}</h3>${nb?`<p class="th-prev">${esc(cut(nb,600))}</p>`:""}<div class="th-meta">${(V.selfTags||[]).filter(g=>g!=="archive"&&g!=="focus").map(g=>`<a class="th-tl" href="#" data-go="/thoughts?tag=${encodeURIComponent(g)}">#${esc(g)}</a>`).join("")}<span class="small">can't be archived or deleted: Dissolve focus ends it</span>${V.created?`<span class="th-date">${TH.day(V.created)}</span>`:""}</div></article>${ts.map(m=>{const body=String(m.text||"").split("\n").slice(1).join("\n").trim();
const tg=m.tags||[],has=g=>tg.includes(g),sp=TH.split(m.text||""),pp=TH.parts(sp.orig),ansT=sp.ans?TH.ansText(sp.ans):"",working=has("working"),sent=has("sent"),ans=["waiting","ai-question","done"].some(has);
const tgl=tg.filter(g=>g!=="archive"&&g!=="focus").map(g=>`<a class="th-tl${FLOWT.has(g)?" th-st t-"+g:""}" href="#" data-go="/thoughts?tag=${encodeURIComponent(g)}">#${esc(g)}</a>`).join("");
const wait=working?`<p class="small th-wait">@designer is on it</p>`:sent?`<p class="small th-wait">Waiting for @designer</p>`:"";
return `<article class="th-card${has("important")?" imp":""}${ans?" ask":""}${working?" working":sent?" sent":""}${ed.k==="thought"&&ed.n===m.n?" fo-open":""}" data-n="${m.n}"><h3 class="th-title">${esc(pp.title||label(m))}</h3>${pp.preview?`<p class="th-prev">${esc(pp.preview)}</p>`:""}${ansT?`<p class="th-ansp">↳ ${esc(ansT)}</p>`:""}${wait}
<div class="th-meta">${tgl}${(m.also||[]).map(a=>`<span class="th-infocus">${esc(a.title)}</span>`).join("")}${m.project!==V.project?`<span class="th-chip th-proj">${esc(m.project)}</span>`:""}${m.created?`<span class="th-date" title="${esc(m.created)}">${TH.day(m.created)}</span>`:""}</div></article>`}).join("")}</div></section>`}
function head(){const n=V.members.length,one=thoughts().length===1&&!topics().length;
return `<div class="fo-head"><h1>${esc(V.title)}</h1><span class="fo-mark">FOCUS · ${n}</span><span class="fo-ab" role="group" aria-label="Map view"><button type="button" data-ab="A" class="${MODE==="A"?"on":""}" aria-pressed="${MODE==="A"}" title="Option A: the map as it was">A</button><button type="button" data-ab="B" class="${MODE==="B"?"on":""}" aria-pressed="${MODE==="B"}" title="Option B: the brain">B</button></span><span class="th-info"><button type="button" class="th-ib" id="fo-ib" aria-label="More info" aria-expanded="false">?</button><span class="th-ip" id="fo-ip" role="note" hidden>A Focus gathers the thoughts and topics on one subject. The map shows its topics on the right and the model they name on the left; tap one to read it on the right. Tap a card to edit it in the box; open the focus card to send the Focus to your designer, as one task or split by feature, or to dissolve it.<span class="th-ih">More in <a href="#" data-go="/topics/Help:Focus">Help: Focus</a></span></span></span>${V.sent?`<span class="fo-sentb">✓ Sent</span>`:""}</div>`}
let SPLIT=(()=>{try{return localStorage.getItem("fo-split")||"both"}catch(_){return "both"}})();
function setSplit(s){SPLIT=s;try{localStorage.setItem("fo-split",s)}catch(_){}const el=$("fo-split");if(el)el.dataset.show=s}
document.addEventListener("click",e=>{const b=e.target.closest&&e.target.closest(".fo-split .fold [data-fold]");if(!b)return;
setSplit(b.dataset.fold==="l"?(SPLIT==="left"?"both":"right"):(SPLIT==="right"?"both":"left"))});
function render(){
const keep=new Set([...document.querySelectorAll(".fo-pick:checked")].map(i=>i.dataset.n)); // ticks survive a redraw
const main=$("fo-main"),folded=(()=>{try{return !!localStorage.getItem("fo-bfold")}catch(_){return false}})();
let first=false;try{first=!localStorage.getItem("fo-seen");if(first)localStorage.setItem("fo-seen","1")}catch(_){}
/* the map is drawn from the first open, even with no members: the core, the model lobe from the notes, the grey related topics (razie, chat 6acae83d, 2026-10-11) */ const small=false,empty=V.members.length<1&&!String(V.notes||"").trim();
main.innerHTML=`<p class="small fo-back"><a href="#" data-go="/thoughts">‹ Thoughts</a></p>${head()}
${first?`<div class="fo-first" id="fo-first">New here: tap a node on the map to read it on the right, ⌄ folds the map to one line, and ? by the title says more. <button type="button" class="btn sm" id="fo-firstok">Got it</button></div>`:""}
<div class="fo-split" id="fo-split" data-show="${SPLIT}"><div class="fo-lp">
${small?`<div id="fo-empty"><p>Nothing here yet. Add thoughts and topics on this subject, then work them down.</p></div>`:`<div class="fo-brain${folded?" fo-bfolded":""}" id="fo-brain"><button type="button" class="ibtn th-bfold fo-bfold" id="fo-bfold" aria-expanded="${!folded}" title="${folded?"Open the map":"Fold the map"}">${folded?"⌃":"⌄"}</button>${mapSvg()}<p class="th-bcap fo-bcap" id="fo-bcap" aria-live="polite"></p></div>
${(V.related||[]).length?`<p class="small" style="text-align:right;margin:4px 0 8px">Related topics in grey; Add makes them members</p>`:""}`}
${empty?`<div id="fo-empty"><p class="small">Nothing here yet. Add thoughts and topics on this subject, then work them down.</p></div>`:""}</div>
<div class="fold" role="group" aria-label="Show panes"><button type="button" data-fold="l" title="Fold the left pane" aria-label="Fold the left pane"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><defs><linearGradient id="fo-g3l" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--fold-hi)"/><stop offset="1" stop-color="var(--fold-lo)"/></linearGradient></defs><path d="M17 3 L5 12 L17 21 Z" fill="url(#fo-g3l)" stroke="var(--fold-edge)" stroke-width=".8" stroke-linejoin="round"/><path d="M16 5 L7 12" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.2" stroke-linecap="round"/></svg></button>
<button type="button" data-fold="r" title="Fold the right pane" aria-label="Fold the right pane"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><defs><linearGradient id="fo-g3r" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--fold-hi)"/><stop offset="1" stop-color="var(--fold-lo)"/></linearGradient></defs><path d="M7 3 L19 12 L7 21 Z" fill="url(#fo-g3r)" stroke="var(--fold-edge)" stroke-width=".8" stroke-linejoin="round"/><path d="M8 5 L17 12" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.2" stroke-linecap="round"/></svg></button></div>
<div class="fo-rp"><div id="fo-panel">${(()=>{const p=panel();return nav()+p})()}</div><p class="small" id="fo-msg"></p><div id="fo-below">${cards()}</div></div></div>`;
const tx=$("fo-text");if(tx){window.thTX(tx);const em=ed.k==="thought"?memberOf(ed.n):null;tx.value=ed.k==="notes"?V.notes||"":em?em.text||"":"";if(V.sent&&ed.k==="notes")tx.contentEditable="false";const sb=$("fo-edsend"),sl=()=>{if(sb){const a=atOf(tx.value);sb.textContent=a?"Send → @"+a:"Send ▾"}};sl();
tx.addEventListener("input",()=>{sl();if(ed.k==="notes"){dirty=true;$("fo-saved").textContent="…";clearTimeout(timer);timer=setTimeout(saveNotes,800)}follow(tx.value)});if(ed.k!=="notes"){setTimeout(()=>{tx.focus();follow(tx.value)},0)}}
const nt=$("fo-newtext");if(nt)window.thTX(nt);
document.querySelectorAll(".fo-cedit").forEach(b=>{window.thTX(b);const m=V.members.find(x=>x.n===+b.dataset.n);b.value=m?m.text||"":"";setTimeout(()=>{b.focus();b.selectionStart=b.selectionEnd=b.value.length},0)});
document.querySelectorAll(".fo-mopen").forEach(sec=>{const h=sec.querySelector(".fo-edhead");if(!h||h.querySelector(".fo-exp")||!sec.querySelector(".fo-mtext.fo-md"))return;const b=document.createElement("button");b.type="button";b.className="ibtn fo-exp";b.title="Expand";b.setAttribute("aria-label","Expand");b.textContent="⤢";b.onclick=e=>{e.stopPropagation();const big=sec.classList.toggle("fo-big");b.textContent=big?"⤡":"⤢";b.title=big?"Back to the editor size":"Expand";b.setAttribute("aria-label",b.title)};h.append(b)});
if(view.k==="topic"){const tp=$("fo-tptext");if(tp){const nm=tp.dataset.name,pr=tp.dataset.project;api("GET","/api/v2/topics/"+encodeURIComponent(nm)+"?format=raw"+(pr&&pr!==PROJ?"&project="+encodeURIComponent(pr):"")).then(j=>{const raw=typeof j==="string"?j:(j&&(j.text||j.body))||"";const t=$("fo-tptext");if(t&&raw)t.innerHTML=md(String(raw).replace(/^---\n[\s\S]*?\n---\n/,""))}).catch(()=>{})}}
if(view.k==="rel"){const r=V.related[view.i];api("GET","/api/v2/topics/"+encodeURIComponent(r.name)+"?format=json").then(j=>{const t=$("fo-rptext");if(t)t.innerHTML=md(String(j.text||j.body||"").replace(/^---\n[\s\S]*?\n---\n/,""))}).catch(()=>{const t=$("fo-rptext");if(t)t.textContent="(can't read it from here: Open full)"})}
document.querySelectorAll(".fo-pick").forEach(i=>{if(keep.has(i.dataset.n))i.checked=true});
if(view.k==="model")cap(capModel(view.i));else if(view.k==="topic")cap(capTopic(view.n));else cap(capHome());
upd();mountBrain()}
// the classes (THO-89: from its own words, the notes and its thoughts; razie 2026-10-10 "don't see classes on left"): the
// read's model has none unless a member thought names one, so the page matches the brain's classes here too
let BRAIN=null;const brain=()=>BRAIN||(BRAIN=api("GET","/api/v2/brain").then(d=>d&&d.C||[]).catch(()=>[]));
const wordsOf=c=>[c.name,c.name+"s",c.name.replace(/([a-z])([A-Z])/g,"$1 $2"),...(c.also||[])].map(w=>String(w).toLowerCase());
async function addClasses(j){const C=await brain();if(!C.length)return;const have=new Set((j.model||[]).map(x=>String(x.label).toLowerCase()));
const ts=(j.members||[]).filter(m=>m.kind==="thought"&&!m.missing),tp=(j.members||[]).filter(m=>m.kind==="topic"&&!m.missing);
const hit=(t,c)=>{const low=" "+String(t||"").toLowerCase().replace(/[^a-z0-9 ]+/g," ").replace(/\s+/g," ")+" ";return wordsOf(c).some(w=>low.includes(" "+w+" "))};
const add=[];for(const c of C){if(have.has(c.name.toLowerCase()))continue;const inNotes=hit(j.notes,c),by=ts.filter(m=>hit(m.text,c));if(!inNotes&&!by.length)continue;
add.push({id:"c:"+c.name,kind:"concept",label:c.name,count:(inNotes?1:0)+by.length,thoughts:by.map(m=>m.project+"/"+m.name),topics:tp.filter(m=>hit(m.text,c)).map(m=>m.n),title:"Class · "+c.name,
text:"class "+c.name+" (\n"+(c.fields||[]).map(f=>" "+f.name+": "+f.type+(f.many?"*":"")).join(",\n")+"\n)"+(c.group?" @group(\""+c.group+"\")":"")+(c.tip?"\n\n"+c.tip:""),href:"/search?q="+encodeURIComponent(c.name)})}
add.sort((a,b)=>b.count-a.count);j.model=[...add,...(j.model||[])]}
async function load(keep){try{const j=await api("GET",fpath());await addClasses(j);
await Promise.all([...j.members.filter(m=>m.kind==="thought"&&!m.missing).map(m=>api("GET","/api/v2/thoughts/"+enc(m.name)+pq(m.project||PROJ)).then(r=>{m.tags=r.tags||[];m.created=r.created;if(r.text!=null)m.text=r.text;if(r.ver!=null)m.ver=r.ver}).catch(()=>{})),
api("GET","/api/v2/thoughts/"+enc(NAME)+pq(PROJ)).then(r=>{j.selfTags=r.tags||[];j.created=r.created}).catch(()=>{})]);V=j;document.title="Focus - "+(j.title||"");if(!keep||!dirty)render()}
catch(e){$("fo-main").innerHTML=`<p class="small fo-back"><a href="#" data-go="/thoughts">‹ Thoughts</a></p><h1>Focus</h1><p class="bad">Can't open this Focus: ${esc(why(e))}</p>`}}
// ---- saving ----
async function saveNotes(){clearTimeout(timer);if(!dirty||!V||ed.k!=="notes")return;dirty=false;const tx=$("fo-text");if(!tx)return;const notes=tx.value.trim();
try{const cur=await api("GET","/api/v2/thoughts/"+enc(NAME)+pq(PROJ));const L=String(cur.text||"").replace(/\r\n/g,"\n").split("\n");
let h=-1;for(let i=0;i<L.length;i++)if(/^##\s+In this focus\s*$/i.test(L[i]))h=i;const above=h<0?L:L.slice(0,h),f=above.findIndex(l=>l.trim());
const text=(f<0?"":above.slice(0,f+1).join("\n"))+"\n\n"+(notes?notes+"\n\n":"")+(h<0?"":L.slice(h).join("\n"));
const j=await api("PUT","/api/v2/thoughts/"+enc(NAME)+pq(PROJ),{text:text.replace(/^\n+/,""),ver:cur.ver});V.notes=notes;V.ver=j.ver;const s=$("fo-saved");if(s)s.textContent="Saved"}
catch(e){dirty=true;const s=$("fo-saved");if(s)s.textContent="";say("Not saved: "+why(e))}}
const memberOf=n=>V.members.find(x=>x.n===+n);
async function keepEd(){const m=ed.k==="thought"?memberOf(ed.n):null,b=$("fo-text");if(!m||!b||b.value===(m.text||""))return;try{const j=await api("PUT",tpath(m),{text:b.value,ver:m.ver});m.ver=j.ver;m.text=b.value}catch(e){say("Not saved: "+why(e))}}
async function saveEd(send){const m=ed.k==="thought"?memberOf(ed.n):null,b=$("fo-text");if(!m||!b)return;
try{if(send){await api("POST",tpath(m,"send"),{text:b.value,ver:m.ver,focus:KEY});say("Sent: the answer comes back in the thought.",true)}
else{const j=await api("PUT",tpath(m),{text:b.value,ver:m.ver});m.ver=j.ver;m.text=b.value;say("Saved.",true)}ed={k:"new"};await load()}catch(e){say((send?"Not sent: ":"Not saved: ")+why(e))}}
async function saveCard(n,send){const m=memberOf(n),b=document.querySelector(`.fo-cedit[data-n="${n}"]`);if(!m||!b)return;
try{if(send){await api("POST",tpath(m,"send"),{text:b.value,ver:m.ver,focus:KEY});say("Sent: the answer comes back in the thought.",true)}
else{const j=await api("PUT",tpath(m),{text:b.value,ver:m.ver});m.ver=j.ver;m.text=b.value;say("Saved.",true)}openCard=null;await load()}catch(e){say((send?"Not sent: ":"Not saved: ")+why(e))}}
async function takeOut(ms){for(const m of ms){await api("POST",fpath("remove"),{member:{project:m.project,name:m.name}});await api("POST",tpath(m,"streak"),{on:true}).catch(()=>{})}}
// ---- the ask popup (Send's switch, Dissolve) ----
let onGo=null;const ask=(text,lbl,danger,withSw,fn)=>{const d=$("fo-ask");$("fo-askt").textContent=text;$("fo-askgo").textContent=lbl;$("fo-askgo").classList.toggle("fo-danger",!!danger);$("fo-asksw").hidden=!withSw;onGo=fn;if(d.showModal)d.showModal();else if(confirm(text))fn()};
$("fo-ask").addEventListener("click",e=>{const s=e.target.closest("#fo-asksw span");if(s)$("fo-asksw").querySelectorAll("span").forEach(x=>x.classList.toggle("on",x===s));
if(e.target.closest("#fo-askno")){e.preventDefault();$("fo-ask").close()}if(e.target.closest("#fo-askgo")){e.preventDefault();$("fo-ask").close();if(onGo)onGo()}});
const picked=()=>[...document.querySelectorAll(".fo-pick:checked")].map(i=>memberOf(i.dataset.n)).filter(Boolean);
function upd(){const p=picked(),o=$("fo-outn");document.querySelectorAll("#fo-cards .th-card").forEach(a=>{const i=a.querySelector(".fo-pick");a.classList.toggle("fo-picked",!!(i&&i.checked))});if(o){o.hidden=!p.length;o.textContent="Take "+p.length+" out"}}
async function show(v){if(dirty)await saveNotes();view=v;render();const p=$("fo-panel");if(p)p.scrollIntoView({block:"nearest",behavior:"smooth"})}
function tool(k,box){if(!box)return;const s=box.selectionStart,e=box.selectionEnd,v=box.value,sel=v.slice(s,e);box.focus();box.setSelectionRange(s,e);
const ins={b:`**${sel||"bold"}**`,i:`*${sel||"italic"}*`,h:`\n## ${sel||"Heading"}\n`,ul:`\n- ${sel}`,ol:`\n1. ${sel}`,link:`[[${sel||"Topic"}]]`,tag:`#${sel||"tag"}`}[k];document.execCommand("insertText",false,ins)}
document.addEventListener("click",async e=>{const t=e.target;if(!t.closest||t.closest("#fo-ask"))return;
const g=t.closest("[data-go]");if(g){e.preventDefault();if(dirty)await saveNotes();go(g.dataset.go);return}
if(t.closest("#fo-ib")){e.stopPropagation();const ip=$("fo-ip");ip.hidden=!ip.hidden;$("fo-ib").setAttribute("aria-expanded",String(!ip.hidden));return}
if(!t.closest(".th-info")){const ip=$("fo-ip");if(ip&&!ip.hidden){ip.hidden=true;$("fo-ib").setAttribute("aria-expanded","false")}}
if(t.closest("#fo-firstok")){$("fo-first").remove();return}
if(t.closest("#fo-bfold")){e.preventDefault();const b=$("fo-brain"),f=!b.classList.contains("fo-bfolded");b.classList.toggle("fo-bfolded",f);const bt=$("fo-bfold");bt.textContent=f?"⌃":"⌄";bt.title=f?"Open the map":"Fold the map";bt.setAttribute("aria-expanded",String(!f));try{localStorage.setItem("fo-bfold",f?"1":"")}catch(_){}return}
const tl=t.closest(".fo-tool");if(tl){e.preventDefault();tool(tl.dataset.md,$(tl.parentNode.dataset.for));return}
if(t.closest("#fo-add")){const m=$("fo-addmenu");m.hidden=!m.hidden;$("fo-add").setAttribute("aria-expanded",String(!m.hidden));return}
if(t.closest('[data-act="new"]')){e.preventDefault();if(dirty)await saveNotes();ed={k:"new"};view={k:"notes"};render();return}
if(t.closest("#fo-newsave")){const v=$("fo-text").value.trim();if(!v)return;try{const c=await api("POST","/api/v2/thoughts",{text:v,project:PROJ});await api("POST",fpath("add"),{members:[{project:c.project||PROJ,name:c.name}]});say("Saved in this Focus.",true);ed={k:"new"};view={k:"notes"};await load()}catch(er){say("Not saved: "+why(er))}return}
if(t.closest(".fo-tpback")){show({k:"notes"});return}
const ra=t.closest(".fo-rpadd");if(ra){try{await api("POST",fpath("add"),{members:[{project:PROJ,name:ra.dataset.name}]});say("Added to this Focus.",true);view={k:"notes"};await load()}catch(er){say("Not added: "+why(er))}return}
const rg=t.closest(".fo-relg");if(rg){show({k:"rel",i:+rg.dataset.rel});return}
const mn=t.closest("[data-m]");if(mn&&!t.closest("#fo-cards")){e.preventDefault();show({k:"model",i:+mn.dataset.m});return}
const nn=t.closest("[data-n]");if(nn&&!t.closest("#fo-cards")){e.preventDefault();const n=+nn.dataset.n;show(n?{k:"topic",n}:{k:"notes"});return}
if(t.closest(".fo-pick")||t.closest(".th-tick")){setTimeout(upd,0);return}
if(t.closest("#fo-outn")){const p=picked();if(!p.length)return;try{await takeOut(p);say((p.length===1?"1 thought":p.length+" thoughts")+" taken out: back in Thoughts and the streak.",true);await load()}catch(er){say("Not taken out: "+why(er))}return}
if(t.closest(".fo-fcard")){if(ed.k!=="notes"||view.k!=="notes"){ed={k:"notes"};view={k:"notes"};render()}return}
if(t.closest("#fo-edsave")){saveEd(false);return}const rp=t.closest("[data-sendto]");if(rp){const r=rp.dataset.sendto,tx=$("fo-text"),pk=$("fo-sendpick");if(pk)pk.remove();if(r&&tx){if(atOf(tx.value)!==r)tx.value="@"+r+" "+tx.value;saveEd(true)}return}
if(t.closest("#fo-edsend")){const tx=$("fo-text");if(!tx||!tx.value.trim())return;if(atOf(tx.value)){saveEd(true);return}if($("fo-sendpick"))return;const b=t.closest("#fo-edsend");b.insertAdjacentHTML("afterend",`<span id="fo-sendpick" class="fo-sendpick" role="menu" aria-label="Send to">${RL.map(r=>`<button type="button" class="btn sm" role="menuitem" data-sendto="${r}"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${RC[r]};margin-right:5px"></span>@${r}</button>`).join("")}<button type="button" class="btn sm" data-sendto="" aria-label="Don't send">✕</button></span>`);return}if(t.closest("#fo-edback")){ed={k:"notes"};render();return}
if(t.closest("#fo-edarch")){const m=memberOf(ed.n);if(m){try{await api("POST",tpath(m,"archive"),{on:true});await api("POST",fpath("remove"),{member:{project:m.project,name:m.name}});say("Archived, and out of this Focus.",true);ed={k:"notes"};await load()}catch(er){say("Not archived: "+why(er))}}return}
if(t.closest("#fo-eddel")){const m=memberOf(ed.n);if(m)ask("Delete this thought? It goes to your Trash, from where you can bring it back.","Delete",true,false,async()=>{try{await api("POST",fpath("remove"),{member:{project:m.project,name:m.name}});await api("DELETE",tpath(m));say("Deleted (in your Trash).",true);ed={k:"notes"};await load()}catch(er){say("Not deleted: "+why(er))}});return}
if(t.closest("#fo-notesave")){dirty=true;await saveNotes();say("Saved.",true);return}
if(t.closest("#fo-edout")){const m=memberOf(ed.n);if(m){try{await takeOut([m]);say("Taken out: it's back in Thoughts.",true);ed={k:"notes"};await load()}catch(er){say("Not taken out: "+why(er))}}return}
const lp=t.closest("[data-plus]");if(lp){e.preventDefault();plusPick(lp.dataset.plus);return}
if(t.closest("#fo-addpick"))return;
const cd=t.closest("#fo-cards .th-card");if(cd){const n=+cd.dataset.n;
if(t.closest(".fo-csave")){saveCard(n,false);return}if(t.closest(".fo-csend")){saveCard(n,true);return}if(t.closest(".fo-cclose")){openCard=null;render();return}
if(!(ed.k==="thought"&&ed.n===n)||view.k!=="notes"){if(dirty)await saveNotes();await keepEd();ed={k:"thought",n};view={k:"notes"};render();$("fo-ed").scrollIntoView({block:"nearest",behavior:"smooth"})}return}
if(t.closest("#fo-send")){const nt=thoughts().length,np=topics().length;ask(`Send this Focus to your designer: ${nt} thought${nt===1?"":"s"}, ${np} topic link${np===1?"":"s"}${(V.notes||"").trim()?" and your notes":""}?`,"Send",false,true,async()=>{if(dirty)await saveNotes();const mode=$("fo-asksw").querySelector(".on").dataset.v;
try{const r=await api("POST",fpath("send"),{mode});say("Sent as "+(mode==="one"?"one task":"tasks split by feature")+(r&&r.tasks&&r.tasks.length?" ("+r.tasks.length+")":"")+".",true);await load()}catch(er){say("Not sent: "+why(er))}});return}
if(t.closest("#fo-archfoc")){ask("Archive this Focus and all its thoughts? You find them under Archived on Thoughts.","Archive",false,false,async()=>{if(dirty)await saveNotes();try{for(const m of thoughts())await api("POST",tpath(m,"archive"),{on:true});await api("POST","/api/v2/thoughts/"+enc(NAME)+"/archive"+pq(PROJ),{on:true});go("/thoughts")}catch(er){say("Not archived: "+why(er))}});return}
if(t.closest("#fo-dissolve")){ask("Dissolve this Focus? Its thoughts go back to Thoughts and the streak, and your notes go into the first one. The topics are not changed.","Dissolve",true,false,async()=>{if(dirty)await saveNotes();
try{const ts=thoughts(),notes=(V.notes||"").trim();if(notes&&ts[0]){const c=await api("GET",tpath(ts[0]));await api("PUT",tpath(ts[0]),{text:String(c.text||"").replace(/\s+$/,"")+"\n\n"+notes,ver:c.ver})}
await takeOut(ts);await api("POST","/api/v2/thoughts/"+enc(NAME)+"/archive"+pq(PROJ),{on:true});go("/thoughts")}catch(er){say("Not dissolved: "+why(er))}});return}
},false);
document.addEventListener("keydown",e=>{if((e.key==="Enter"||e.key===" ")&&e.target.closest&&e.target.closest('#fo-map [role="button"]')){e.preventDefault();e.target.dispatchEvent(new MouseEvent("click",{bubbles:true}))}});
// ---- kept current: the thoughts stamp, re-read when the revs stream says thoughts moved (^revs-3); never while you type or edit a card ----
const editing=()=>{const a=document.activeElement;return !!(a&&(a.id==="fo-text"||a.id==="fo-newtext"||(a.classList&&a.classList.contains("fo-cedit"))))};
// ---- live from the revs stream (^revs-3): re-read only when these stores moved, through d2's own d2revs (one stream per tab, its 10 s /revs fallback); a sandboxed copy without it reads /revs every 10 s; a move skipped while you type is retried ----
function d2live(keys,fn){let due=false;const run=async()=>{if(!due)return;try{if(await fn()!==false)due=false}catch(e){}};const moved=()=>{due=true;run()};
if(window.d2revs)window.d2revs(keys,moved);else{let last=null;setInterval(()=>{if(document.hidden)return;api("GET","/api/v2/revs").then(r=>{if(!r||typeof r!=="object")return;const s=JSON.stringify(keys.map(k=>r[k]));if(last!==null&&s!==last)moved();last=s},()=>{})},10000)}
setInterval(run,10000);document.addEventListener("visibilitychange",()=>{if(!document.hidden)run()})}
d2live(["thoughts"],async()=>{if(!V||dirty||editing()||openCard!==null||document.hidden||(window.d2typingAt&&performance.now()-window.d2typingAt<3000))return false;
try{const s=await api("GET","/api/v2/thoughts/stamp");if(s&&s.stamp&&s.stamp!==stamp){stamp=s.stamp;await load()}}catch(_){}});
if(!PARAMS.name||!PROJ){$("fo-main").innerHTML=`<h1>Focus</h1><p class="bad">Which Focus? This page opens as /thoughts/focus/<handle>-<n>?project=<project>.</p>`}else{api("GET","/api/v2/thoughts/stamp").then(s=>{stamp=s&&s.stamp||""}).catch(()=>{});load()}
</script></body></html>