app
Adom Project Manager
Public Made by Adomby adom
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 Manag
← 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
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: <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='“'+noun+'”'; 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>',