← Commit history

v0.12.0: adopt the adom-ui-design 1.22.1 dock-dashboard contract (newbie panel as a full-width two-column band with the prompts as a left-packed table, real ?-toggle + X that fully closes, header name/mark to spec, target chip, pack cog, no layout width caps) AND fix issue #8 — resolve the bridge CLI name at runtime (adom-bridge / adom-bridge-cli / adom-desktop) so Add to Library and Add Project work again, bounded at 20s so a gated verb can never hang a fetch

John Lauer ·3d5e55c0c7 ·1mo ago ·parent a2fff3b
7 files changed +237−10
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-project-manager"-version = "0.11.6"+version = "0.12.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-project-manager"-version = "0.11.6"+version = "0.12.0" edition = "2021" description = "Adom Project Manager — the central hub for navigating wiki boards, molecules, and libraries into Hydrogen: browse your wiki boards/projects, view symbol + footprint + 3D and the footprint layer stackup, add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr." 
SKILL.md+1−1
@@ -38,7 +38,7 @@ the user's webview panel, use `adom-cli hydrogen webview open-or-refresh`.  ## Prerequisites -- **adom-desktop** bridge running — delivery to the user's machine (Add to+- **Adom Bridge** running — delivery to the user's machine (Add to   Library / Add Project, project fetch) and desktop screenshots go through it. - Signed-in **adom-wiki** session — identity (Personal/Org tabs) and stars. - **python3** in the container — the self-containment step for Add Project.
bin/adom-project-manager
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-project-manager",-  "version": "0.11.6",+  "version": "0.12.0",   "type": "app",   "description": "Adom Project Manager — the central hub for navigating wiki boards, molecules and libraries into Hydrogen: browse your wiki boards/projects (Personal / Organization / Public), view symbol + footprint + 3D and the per-EDA footprint layer stackup, explore the board and schematic in INTERACTIVE cross-probing viewers (click a net, hover a part for stock and price), add components to your KiCad/Altium libraries, and deliver self-contained KiCad projects. Built on adom-lbr. (Formerly Adom Project Manager / adom-project-manager.)",   "dependencies": {
src/main.rs+36−3
@@ -532,7 +532,7 @@ pub fn run(port: u16) -> Result<()> {     // rule bans the localhost/proxy forms, and this line is scraped when the     // binary is run directly rather than through `serve`.     println!("OK: Adom Project Manager on http://127.0.0.1:{port}/");-    println!("Hint: open it in a browser, or `adom-desktop desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");+    println!("Hint: open it in a browser, or `adom-bridge desktop_open_url {{\"url\":\"http://localhost:{port}\"}}`.");     println!("Hint: the component list is fetched from {WIKI}; click a row to open the viewer.");     // Handle each request on its own thread. Many endpoints block on the desktop     // bridge (folder listing, library scans, renders); a single-threaded loop would@@ -1741,8 +1741,41 @@ const AD_REASON: &str =  /// An `adom-desktop` invocation carrying this app's identity. Every bridge call goes /// through here; a call without `--ai-thread` is refused before it ever reaches the desktop.+/// The bridge CLI's name on THIS workspace. It was `adom-desktop`, then+/// `adom-bridge-cli`, and is now `adom-bridge`; no compatibility alias was left+/// behind, so hardcoding any one of them silently kills the whole delivery half+/// (Add to Library / Add Project) on every workspace that has a different one —+/// page issue #8, where `/api/env` reports the CONTAINER because every bridge+/// call fails. Resolved once, newest name first, so it is right on any vintage.+fn ad_bin() -> &'static str {+    static RESOLVED: Mutex<Option<&'static str>> = Mutex::new(None);+    let mut g = RESOLVED.lock().unwrap();+    if let Some(b) = *g {+        return b;+    }+    let found = ["adom-bridge", "adom-bridge-cli", "adom-desktop"]+        .into_iter()+        .find(|b| {+            Command::new("sh")+                .args(["-c", &format!("command -v {b}")])+                .stdout(std::process::Stdio::null())+                .stderr(std::process::Stdio::null())+                .status()+                .map(|s| s.success())+                .unwrap_or(false)+        })+        .unwrap_or("adom-bridge");+    *g = Some(found);+    found+}+ fn ad_cmd() -> Command {-    let mut c = Command::new("adom-desktop");+    // BOUNDED. With the binary name correct the call actually reaches the bridge,+    // and a gated verb can then sit waiting on a human approval that may never+    // come — which would hang a dashboard fetch rather than failing it. Nothing+    // here is worth more than 20s; past that we degrade to "no machine".+    let mut c = Command::new("timeout");+    c.args(["20", ad_bin()]);     c.args(["--ai-thread", AI_THREAD]);     if let Ok(name) = std::env::var("ADOM_CONTAINER_NAME") {         if !name.trim().is_empty() {@@ -2490,7 +2523,7 @@ fn ad_shell_out_why(cmd: &str, why: &str) -> Option<String> {     let raw = String::from_utf8_lossy(&out.stdout).to_string();     if let Ok(v) = serde_json::from_str::<Value>(&raw) {         if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {-            eprintln!("WARN: adom-desktop refused shell_execute ({code}): {cmd}");+            eprintln!("WARN: bridge refused shell_execute ({code}): {cmd}");             return None;         }     }
src/manage.html+197−3
@@ -39,7 +39,7 @@   #home{flex:1;min-height:0;display:none;flex-direction:column;background:var(--bg)}   #workspace.home #home{display:flex}   #workspace.home #list,#workspace.home #view{display:none}-  .home-bar{display:flex;align-items:center;gap:16px;padding:16px 22px 12px;flex-wrap:wrap;max-width:1240px;margin:0 auto;width:100%}+  .home-bar{display:flex;align-items:center;gap:16px;padding:16px 22px 12px;flex-wrap:wrap;width:100%}   .seg{display:inline-flex;background:var(--elevated);border:1px solid var(--edge);border-radius:9px;padding:3px;gap:2px}   .seg .segbtn{background:none;border:none;color:var(--mut);font:inherit;font-size:12.5px;font-weight:500;padding:6px 15px;border-radius:6px;cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:7px;transition:color .12s,background .12s}   .seg .segbtn:hover{color:var(--ink)}@@ -55,13 +55,13 @@   .home-sort{font-size:12px;color:var(--mut);display:flex;align-items:center;gap:7px;white-space:nowrap}   .home-sort select{background:var(--elevated);color:var(--ink);border:1px solid var(--edge);border-radius:8px;font-size:12px;padding:7px 26px 7px 10px;font-family:inherit;cursor:pointer;-webkit-appearance:none;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%238b93a3' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 9px center}   .home-sort select:focus{outline:none;border-color:var(--acc)}-  .home-chips{display:flex;flex-wrap:wrap;gap:6px;padding:0 22px 12px;max-width:1240px;margin:0 auto;width:100%}+  .home-chips{display:flex;flex-wrap:wrap;gap:6px;padding:0 22px 12px;width:100%}   /* Clear breathing room between the filter chips and the first row/section header. */   #home-scroll{flex:1;min-height:0;overflow:auto;padding:14px 22px 34px}   /* Fixed 3-wide wall. A centered max-width keeps cards a consistent (bigger) size once      the window is wide enough — past ~1240px the grid stops growing instead of stretching.      Narrower viewports gracefully drop to 2 then 1 column. */-  .gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:28px;align-content:start;max-width:1240px;margin:0 auto}+  .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:28px;align-content:start}   @media (max-width:920px){ .gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px} }   @media (max-width:600px){ .gallery{grid-template-columns:1fr;gap:18px} }   .g-sec-head{grid-column:1/-1;display:flex;align-items:center;gap:10px;margin:22px 2px 3px;font-size:12px;font-weight:700;color:var(--text-2,#8b949e);text-transform:uppercase;letter-spacing:.06em}@@ -447,6 +447,71 @@   #tip{position:fixed;z-index:9999;background:#0d1117;border:1px solid var(--acc);color:var(--ink);font:12px 'JetBrains Mono',ui-monospace,monospace;padding:3px 8px;border-radius:6px;pointer-events:none;display:none;white-space:nowrap}   #tip b{color:var(--acc)}   .bo-embed{width:100%;height:100%;border:0;display:block}+  /* ══ adom-ui-design 1.22.1 dock-dashboard contract ══════════════════════════+     Spec token names aliased onto this app's existing palette so the numbers+     below can be copied from the skills verbatim rather than re-derived. */+  :root{--text:var(--ink);--muted:var(--mut);--line:var(--edge);--surface:var(--panel);+        --teal:var(--acc);--on-accent:#052220}++  /* dash-header: the name is TEXT, not branding (15px var(--text), never teal,+     never the slug), and the mark is 18px/1.8 — the same file as the favicon. */+  header.app .app-name{font-size:15px;color:var(--text);letter-spacing:-.01em}+  header.app .brand svg{width:18px;height:18px;flex:0 0 18px;stroke-width:1.8}+  header.app .app-sub{font-size:13px}+  @media (max-width:720px){ header.app .app-sub{display:none} }+  /* Right-hand controls: target chip → ? → cog, same order on every Adom app. */+  .hdr-target{display:inline-flex;align-items:center;gap:7px;font-family:'JetBrains Mono',ui-monospace,monospace;+    font-size:12px;color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:3px 10px;white-space:nowrap}+  .hdr-target b{color:var(--text);font-weight:500}+  .hdr-target .led{width:8px;height:8px;border-radius:50%;background:var(--green);flex:0 0 8px}+  .hdr-target .led.warn{background:var(--yellow)}+  .ghost-btn{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;+    background:none;border:1px solid transparent;border-radius:6px;color:var(--muted);cursor:pointer;padding:0;transition:color .12s,background .12s,border-color .12s}+  .ghost-btn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}+  .ghost-btn:focus-visible{outline:2px solid var(--teal);outline-offset:1px}+  .ghost-btn svg{width:18px;height:18px}+  /* A toggle that looks identical in both states is a lie (input-feedback). */+  .ghost-btn[aria-pressed="true"]{color:var(--teal);background:rgba(0,184,177,.12);border-color:var(--line)}++  /* newbie-panel: ONE full-width band, a SIBLING of the app body. */+  #newbie{margin:16px 22px 20px;background:var(--surface);border:1px solid var(--line);+    border-left:3px solid var(--teal);border-radius:12px;padding:18px 20px 20px;position:relative;flex:0 0 auto}+  #newbie[hidden]{display:none}+  #newbie .nb-h{font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:15px;color:var(--text);margin:0}+  /* Constrain PROSE, never the layout. */+  #newbie .nb-x{font-size:13.5px;color:var(--muted);max-width:70ch;margin:6px 0 14px}+  #newbie .nb-x b{color:var(--teal);font-weight:600}+  #newbie .nb-close{position:absolute;top:14px;right:14px}+  #newbie .nb-cols{display:grid;grid-template-columns:220px 1fr;gap:24px;align-items:start}+  #newbie .nb-right{border-left:1px solid var(--line);padding-left:24px}+  @media (max-width:760px){+    #newbie .nb-cols{grid-template-columns:1fr}+    #newbie .nb-right{border-left:none;border-top:1px solid var(--line);padding-left:0;padding-top:18px}+  }+  /* The panel's ONLY filled button. */+  .demo-btn{background:var(--teal);color:var(--on-accent);border:none;border-radius:8px;padding:10px 18px;+    font-family:'Familjen Grotesk',-apple-system,sans-serif;font-weight:600;font-size:14px;+    display:inline-flex;align-items:center;gap:8px;cursor:pointer}+  .demo-btn:disabled{opacity:.6;cursor:default}+  .demo-btn svg{width:16px;height:16px}+  .demo-sub{font-size:12px;color:var(--muted);margin-top:8px}+  /* Prompts as a TABLE: one left edge, one row height, so the eye reads the+     PHRASES rather than the boxes. The row's actions track the PHRASE, not the+     window edge, so Copy never strands itself 2000px away on a wide monitor. */+  .say-tbl{width:100%}+  .say-row{display:grid;grid-template-columns:minmax(0,max-content) 72px;align-items:center;+    height:40px;border-bottom:1px solid var(--line);cursor:pointer}+  .say-row:last-child{border-bottom:none}+  .say-row:hover{background:rgba(255,255,255,.03)}+  .say-row .sp{font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:14px}+  .say-row .sp em{color:var(--teal);font-style:normal}+  .say-row .sa{display:flex;gap:6px;justify-content:flex-end}+  /* tooltips: 600ms delay, body-appended fixed div, clamped to the viewport.+     NEVER alongside a title attribute on the same element or the browser paints+     its own box on top of ours. */+  #tip{position:fixed;z-index:900;background:var(--overlay);color:var(--text);border:1px solid var(--line);+    border-radius:6px;padding:5px 9px;font-size:12px;pointer-events:none;opacity:0;transition:opacity .1s;white-space:nowrap}+  #tip.on{opacity:1} </style> </head> <body>@@ -495,6 +560,13 @@         </div>       </div>       <button id="refresh" title="refresh list (fetch newly-pushed boards)">↻</button>+      <span class="hdr-target" id="hdr-target" title="Which machine the bridge answers on"><span class="led" id="hdr-led"></span>target:&nbsp;<b id="hdr-box">this machine</b></span>+      <button class="ghost-btn" id="nb-toggle" aria-pressed="true" data-tooltip="Hide the prompts panel" aria-label="Hide the prompts panel">+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 0 1 4.9.6c0 1.7-2.5 2.2-2.5 3.7"/><path d="M12 17h.01"/></svg>+      </button>+      <button class="ghost-btn" id="cog-btn" data-tooltip="Settings" aria-label="Settings">+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>+      </button>     </div>   </header>   <div id="toasts"></div>@@ -506,6 +578,28 @@     </div>     <div class="tv-body"><div class="tv-load" id="tv-load"><img src="loader.gif" width="48" alt=""><span>Starting tool…</span></div><iframe id="tv-frame" title="tool"></iframe></div>   </div>+  <!-- newbie-panel: a SIBLING of the app body (inside the grid it would stay a+       column however wide it is styled). Phrases come from dockbar.json's say[]. -->+  <section id="newbie" aria-label="Sample AI prompts">+    <button class="ghost-btn nb-close" id="nb-close" data-tooltip="Close this panel" aria-label="Close this panel">+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>+    </button>+    <h2 class="nb-h">Say it</h2>+    <p class="nb-x">You never need this page's buttons: just tell your AI what you want, using the words <b>project manager</b>.</p>+    <div class="nb-cols">+      <div class="nb-left">+        <button class="demo-btn" id="demo-btn">+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg>+          Play the demo+        </button>+        <div class="demo-sub" id="demo-sub">a 2-minute tour</div>+      </div>+      <div class="nb-right">+        <div class="say-tbl" id="say-tbl" role="table"></div>+      </div>+    </div>+  </section>+   <div id="workspace">   <!-- HOME: full-screen gallery of boards/molecules/projects/circuits. The left list        only appears once you click into a project (the interior below). -->@@ -574,6 +668,106 @@ function setSubject(name, slug){   if(m) m.textContent=slug&&slug!==name?slug:''; } function setSubjectMeta(txt){ const m=document.getElementById('hdr-meta'); if(m&&boardSlug) m.textContent=txt||''; }+++// tooltips (600ms, viewport-clamped) for every [data-tooltip] control+(function(){+  let tip,timer;+  function show(el){+    if(!tip){ tip=document.createElement('div'); tip.id='tip'; document.body.appendChild(tip); }+    tip.textContent=el.getAttribute('data-tooltip')||''; tip.classList.add('on');+    const r=el.getBoundingClientRect(), t=tip.getBoundingClientRect();+    let left=r.left+r.width/2-t.width/2, top=r.bottom+8;+    left=Math.max(6,Math.min(left,innerWidth-t.width-6));+    if(top+t.height>innerHeight-6) top=r.top-t.height-8;+    tip.style.left=left+'px'; tip.style.top=top+'px';+  }+  function hide(){ clearTimeout(timer); if(tip) tip.classList.remove('on'); }+  document.addEventListener('mouseover',e=>{+    const el=e.target.closest && e.target.closest('[data-tooltip]');+    if(!el) return; clearTimeout(timer); timer=setTimeout(()=>show(el),600);+  });+  document.addEventListener('mouseout',e=>{ if(e.target.closest && e.target.closest('[data-tooltip]')) hide(); });+  document.addEventListener('click',hide,true);+})();++// ══ newbie-panel + dash-header controls (adom-ui-design 1.22.1) ═══════════════+// The phrases are dockbar.json's say[] — the same nouns the dock card teaches.+const SAY_PHRASES=[+  "open my boards in project manager",+  "add this board's parts to my kicad library with project manager",+  "send this whole project to my computer from project manager",+  "show me the layer stackup in project manager",+  "highlight this net in project manager's board view"+];+// Namespaced by app slug, NOT by origin: serve ports are reassigned per launch and+// proxied dashboards share one origin, so the slug is what actually isolates us.+const NB_KEY='adom-project-manager:dash-graduation';+const ICON_COPY='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>';+const ICON_SEND='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2.5 10.5 13.5"/><path d="M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z"/></svg>';++function nbSetOpen(open){+  const panel=document.getElementById('newbie'), btn=document.getElementById('nb-toggle');+  if(!panel||!btn) return;+  panel.hidden=!open;                        // the X FULLY closes it: no stub, no one-liner+  btn.setAttribute('aria-pressed',String(open));+  const label=open?'Hide the prompts panel':'Show the prompts panel';+  btn.setAttribute('aria-label',label); btn.setAttribute('data-tooltip',label);+  try{ localStorage.setItem(NB_KEY,open?'open':'closed'); }catch(e){}+}+async function sayCopy(phrase){+  try{ await navigator.clipboard.writeText(phrase); }+  catch(e){ const t=document.createElement('textarea'); t.value=phrase; document.body.appendChild(t);+            t.select(); try{document.execCommand('copy');}finally{t.remove();} }+  toast('Copied to clipboard. Paste it into any AI thread.','success');+}+// Send is PARKED fleet-wide: refuse before any control-API call and fall back to+// copy, rather than firing a request that cannot be verified by delivered_to.+async function saySend(phrase){+  await sayCopy(phrase);+  toast('Send to AI is not enabled yet — the phrase was copied instead.','info');+}+function nbRender(){+  const box=document.getElementById('say-tbl'); if(!box) return;+  box.innerHTML='';+  SAY_PHRASES.forEach(ph=>{+    const row=document.createElement('div'); row.className='say-row'; row.setAttribute('role','row');+    const noun=ph.replace(/project manager/i,m=>'<em>'+m+'</em>');+    const cell=document.createElement('div'); cell.className='sp'; cell.setAttribute('role','cell');+    cell.innerHTML='&ldquo;'+noun+'&rdquo;'; cell.title=ph;+    const act=document.createElement('div'); act.className='sa'; act.setAttribute('role','cell');+    const mk=(html,label,fn)=>{ const b=document.createElement('button'); b.className='ghost-btn';+      b.innerHTML=html; b.setAttribute('aria-label',label); b.setAttribute('data-tooltip',label);+      b.onclick=e=>{ e.stopPropagation(); fn(); }; return b; };+    act.appendChild(mk(ICON_COPY,'Copy this prompt to your clipboard',()=>sayCopy(ph)));+    act.appendChild(mk(ICON_SEND,'Send this prompt to a new AI thread',()=>saySend(ph)));+    row.appendChild(cell); row.appendChild(act);+    row.onclick=()=>sayCopy(ph);              // the whole row copies+    box.appendChild(row);+  });+}+function nbInit(){+  nbRender();+  let saved=null; try{ saved=localStorage.getItem(NB_KEY); }catch(e){}+  nbSetOpen(saved!=='closed');                // fresh dashboards start expanded+  const close=document.getElementById('nb-close'), tog=document.getElementById('nb-toggle');+  if(close) close.onclick=()=>nbSetOpen(false);+  // A real toggle: pressing it while open closes it, exactly as the X does.+  if(tog) tog.onclick=()=>nbSetOpen(document.getElementById('newbie').hidden);+  const demo=document.getElementById('demo-btn');+  if(demo) demo.onclick=()=>toast('The guided demo is not recorded yet.','info');+  const cog=document.getElementById('cog-btn');+  if(cog) cog.onclick=()=>toast('Settings are coming to this dashboard.','info');+  // The target chip states the box honestly rather than guessing when the+  // container cannot learn its name.+  fetch('api/env').then(r=>r.json()).then(e=>{+    const box=document.getElementById('hdr-box'), led=document.getElementById('hdr-led');+    if(box&&e&&e.machine_name) box.textContent=e.machine_name;+    if(led&&e&&e.os==='linux'&&e.home==='/root') led.classList.add('warn');+  }).catch(()=>{});+}+if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',nbInit); else nbInit();+ // ── toasts: every action answers back; also the AI's live narration channel ── const TOAST_ICONS={   success:'<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',