app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
Publish 1.0.78
5 files changed
+220−112
dashboard/kicad_dashboard.py+48−2@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "3.2.0"+VERSION = "3.3.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -322,7 +322,40 @@ _CRITICAL_LEDS = ("relay", "box", "bridge", "kicad") # can this thing work at _ADVISORY_LEDS = ("update", "focus", "presence", "plugin") # informational, never alarming +def _emit_short_states(leds: dict) -> None:+ """THE SERVER emits each pill's short state (#59). The page used to trim+ `detail` to 30 chars, which is what produced the fleet's "in talled and+ pinned" - a client cannot shorten prose it does not understand. Anything+ without a hand-written short label falls back to the STATE word, never to+ a truncated sentence."""+ SHORT = {+ "relay": lambda l: ("connected" if l.get("state") == "ok" else "no boxes"),+ "box": lambda l: ("online" if l.get("state") == "ok" else "offline"),+ "bridge": lambda l: ("answering" if l.get("state") == "ok" else "not answering"),+ "kicad": lambda l: (str(l.get("detail") or "").strip()+ if l.get("state") == "ok" else "not installed"),+ "update": lambda l: ("current" if l.get("state") == "ok" else "update ready"),+ "plugin": lambda l: (str(l.get("detail") or "").split(" - ")[0].strip()+ if l.get("state") == "ok" else "not bound"),+ "focus": lambda l: ("quiet" if l.get("state") == "ok" else "busy"),+ "presence": lambda l: ("idle" if l.get("state") == "ok" else "active"),+ "suite": lambda l: ("passing" if l.get("state") == "ok" else "check it"),+ }+ for key, led in (leds or {}).items():+ if not isinstance(led, dict):+ continue+ try:+ val = SHORT.get(key, lambda l: None)(led)+ except Exception:+ val = None+ if not val:+ val = {"ok": "ok", "err": "broken", "warn": "degraded",+ "busy": "working", "off": "unknown"}.get(led.get("state"), "unknown")+ led["short"] = str(val)[:24]++ def rollup_state(leds: dict) -> dict:+ _emit_short_states(leds) """Green when the bridge can actually do its job; yellow only when a CRITICAL facet is degraded; red when one is broken.""" def st(k):@@ -1128,11 +1161,24 @@ class Handler(BaseHTTPRequestHandler): log(f"status target={target}{' force' if force else ''}") return self._json(cached_status(target, force)) try: # any other GET renders the app (proxy-friendly: sub-paths still work)+ # no-store: the dock's Hard Refresh Open must never get a cached+ # shell after a version bump (dockbar guide). return self._send(200, "text/html; charset=utf-8",- (UI_DIR / "index.html").read_text(encoding="utf-8"))+ (UI_DIR / "index.html").read_text(encoding="utf-8"),+ {"Cache-Control": "no-store, must-revalidate"}) except Exception as e: return self._json({"error": f"ui missing: {e}"}, 500) + def do_OPTIONS(self):+ # CORS preflight on every /api/* route (dockbar guide): the dock card+ # and the webview both call cross-origin.+ self.send_response(204)+ self.send_header("Access-Control-Allow-Origin", "*")+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")+ self.send_header("Access-Control-Allow-Headers", "Content-Type")+ self.send_header("Access-Control-Max-Age", "86400")+ self.end_headers()+ def do_POST(self): path = urlparse(self.path).path.rstrip("/") or "/" body = self._read_body()
dashboard/ui/index.html+168−105@@ -26,40 +26,101 @@ body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-se ::-webkit-scrollbar-thumb:hover{background:rgba(0,184,177,.55);background-clip:padding-box} ::-webkit-scrollbar-corner{background:transparent} body{overflow-x:hidden} /* the page never scrolls horizontally */-header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--border)}-.brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap;cursor:default}+header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;margin:0;border-radius:0;background:var(--surface);border-bottom:1px solid var(--border)}+.brand{display:flex;align-items:center;gap:8px;font:600 15px var(--sans);color:var(--text);white-space:nowrap;cursor:default} .brand img{width:18px;height:18px;border-radius:4px;display:block} .vsep{width:1px;height:20px;background:var(--border)} .target-chip{display:inline-flex;align-items:center;gap:7px;font:12px 'JetBrains Mono',monospace;color:var(--text-body);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:4px 12px;white-space:nowrap} .target-chip .tdot{width:8px;height:8px} .actions{margin-left:auto;display:flex;gap:8px}-.iconbtn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.iconbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease} .iconbtn:hover{background:var(--elevated);border-color:var(--border)} .iconbtn:active{transform:scale(.9);background:var(--overlay)}-main{max-width:1080px;margin:0 auto;padding:20px 16px 84px}+/* FLUID: no max-width. A dashboard fills its window like a native app; a cap+ leaves dead background that reads as broken (John, 2026-08-24). The content+ region owns the page padding, and it is IDENTICAL panel-open and closed. */+main{margin:0;padding:20px 16px 84px} /* dock-dashboard token stub (1.9.1): new components speak these names */ :root{--surface2:#1c232c;--line:#262d36;--muted:#8b949e;--teal:#00b8b1; --led-green:#3fb950;--led-amber:#d29922;--led-red:#f85149; --sans:'Familjen Grotesk',system-ui,sans-serif;--body:'Satoshi',system-ui,sans-serif; --mono:'JetBrains Mono',ui-monospace,Consolas,monospace} /* say-strip (canonical) */-.say-chips{margin-top:8px}-.say-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:5px 6px 5px 12px;font:400 13.5px var(--body);margin:4px 6px 0 0;cursor:pointer;color:var(--text);transition:border-color .15s ease}-.say-chip:hover{border-color:var(--teal)}-.say-chip em{color:var(--teal);font-style:normal}-.say-chip .copy,.say-chip .send{font:500 11px var(--body);background:none;border:1px solid var(--line);border-radius:999px;padding:2px 9px;cursor:pointer;transition:color .15s ease,border-color .15s ease,background .15s ease}-.say-chip .copy{color:var(--muted)}-.say-chip .send{color:var(--teal);border-color:rgba(0,184,177,.45)}-.say-chip:hover .copy{color:var(--teal);border-color:var(--teal)}-.say-chip .send:hover{background:rgba(0,184,177,.12)}-.say-chip .copy:active,.say-chip .send:active{transform:translateY(1px)}-.say-head{position:relative;display:block;margin-bottom:2px}-.say-head h2{font:600 15px var(--sans);margin:0}-.say-head p{color:var(--muted);font-size:13.5px;margin:4px 0 0;max-width:70ch}-.say-x{position:absolute;top:-4px;right:-6px;background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 8px;border-radius:6px}-.say-x:hover{color:var(--text);background:var(--overlay)}-.say-folded,.fold-line{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;color:var(--muted);cursor:pointer;margin-top:8px}-.say-folded:hover,.fold-line:hover{border-color:var(--teal);color:var(--text)}+/* status-leds pill row */+.leds{display:flex;flex-wrap:wrap;gap:8px}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}+.led-pill:hover{border-color:var(--teal)}+.led-pill:active{transform:translateY(1px)}+.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}+.led-pill[data-state=ok] .dot{background:var(--led-green)}+.led-pill[data-state=busy] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}+.led-pill[data-state=stuck] .dot{background:var(--led-amber)}+.led-pill[data-state=bad] .dot{background:var(--led-red)}+.led-pill .st{color:var(--muted)}+.led-detail-pop{position:fixed;z-index:99998;max-width:360px;background:var(--overlay);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4)}+.led-detail-pop h4{font:600 13px var(--sans);color:var(--text);margin:0 0 6px}+.led-detail-pop .hint{color:var(--muted);margin-top:6px;font-size:12.5px}+.led-detail-pop .winlist{font:12px var(--mono);color:var(--muted);margin-top:6px;white-space:pre-wrap}+/* dash-demo toolbar */+#demoToolbar{position:fixed;left:50%;bottom:56px;transform:translateX(-50%);z-index:99998;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}+.dt-btn{background:none;border:1px solid transparent;border-radius:8px;color:var(--text);font-size:15px;padding:4px 8px;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.dt-btn:hover{background:var(--surface2);border-color:var(--line)}+.dt-btn:active{transform:translateY(1px) scale(.95)}+.dt-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+.dt-sep{width:1px;height:18px;background:var(--line)}+.dt-step{font:12px var(--mono);color:var(--muted);padding:0 4px}+#dtMute.muted{opacity:.45}+#dtExit{color:var(--led-red)}+/* ── newbie-panel (adom-ui-design 1.22.1) ──────────────────────────────────+ ONE full-width band, SIBLING of the app body, two columns: demo left,+ prompts TABLE right. Pills were retired: six phrases of different lengths in+ chips read as a ragged staircase. NO max-width anywhere - a dashboard is an+ app surface, not an article; prose is capped, the layout never is. */+.panel{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);+ border-radius:12px;padding:18px 20px 20px;margin:16px 0 20px;position:relative}+.panel h2{font:600 15px var(--sans);color:var(--text);margin:0}+.panel .explain{font:13.5px/1.5 var(--body);color:var(--muted);margin:4px 0 0;max-width:70ch}+.panel .explain b{color:var(--teal);font-weight:600}+.panel-x{position:absolute;top:12px;right:12px;width:28px;height:28px;display:flex;align-items:center;+ justify-content:center;background:none;border:1px solid transparent;border-radius:6px;+ color:var(--muted);font-size:18px;line-height:1;cursor:pointer}+.panel-x:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}+.panel-x:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+.panel-cols{display:grid;grid-template-columns:220px 1fr;gap:24px;align-items:start;margin-top:14px}+.panel-right{border-left:1px solid var(--line);padding-left:24px;min-width:0}+@media (max-width:760px){+ .panel-cols{grid-template-columns:1fr}+ .panel-right{border-left:none;border-top:1px solid var(--line);padding-left:0;padding-top:16px}+}+.demo-btn{display:inline-flex;align-items:center;gap:8px;background:var(--teal);color:var(--on-accent);+ border:none;border-radius:8px;padding:10px 18px;font:600 14px var(--sans);cursor:pointer;+ transition:filter .15s ease,transform .06s ease}+.demo-btn:hover{filter:brightness(1.08)}+.demo-btn:active{transform:translateY(1px)}+.demo-btn:disabled{opacity:.6;cursor:default;transform:none}+.demo-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+.demo-sub{font:12px var(--body);color:var(--muted);margin-top:8px}+/* prompts TABLE: one left edge, one row height, so the eye reads phrases not boxes */+table.prompts{width:100%;border-collapse:collapse;table-layout:auto}+table.prompts td{padding:0;vertical-align:middle}+table.prompts tr{height:40px;border-bottom:1px solid var(--line);cursor:pointer}+table.prompts tr:last-child{border-bottom:none}+table.prompts tr:hover{background:rgba(255,255,255,.03)}+/* the row is a grid so the ACTIONS TRACK THE PHRASE, not the window edge: a+ Copy button 2000px from its text on a wide monitor is the bug this fixes */+table.prompts tr{display:grid;grid-template-columns:minmax(0,max-content) 72px;align-items:center}+.p-phrase{font:14px var(--body);color:var(--text);white-space:nowrap;overflow:hidden;+ text-overflow:ellipsis;padding-right:16px;min-width:0}+.p-phrase em{color:var(--teal);font-style:normal}+.p-actions{display:flex;gap:6px;width:72px}+.ibtn{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 .15s ease,border-color .15s ease,background .15s ease}+.ibtn svg{width:16px;height:16px}+.ibtn:hover{color:var(--teal);border-color:var(--line);background:rgba(0,184,177,.08)}+.ibtn:active{transform:translateY(1px)}+.ibtn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+@media (pointer:coarse){.ibtn{padding:8px;width:44px;height:44px}} /* status-leds pill row */ .leds{display:flex;flex-wrap:wrap;gap:8px} .led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}@@ -86,22 +147,6 @@ main{max-width:1080px;margin:0 auto;padding:20px 16px 84px} #dtMute.muted{opacity:.45} #dtExit{color:var(--led-red)} /* "Say it": the first thing on the page (John, 2026-08-23: teach users how to ASK for the app by its noun, first and foremost). */-.say{background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:14px 16px}-.say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:10px}-.say-head h2{font-size:17px}-.say-sub{font-size:12.5px;color:var(--text-2);max-width:62ch}-.say-sub b{color:var(--text);font-weight:600}-.say-list{display:flex;flex-wrap:wrap;gap:8px}-.say-item{display:inline-flex;align-items:stretch;border:1px solid var(--border);border-radius:999px;background:var(--elevated);overflow:hidden;transition:border-color .15s ease}-.say-item:hover{border-color:var(--accent)}-.say-phrase{display:inline-block;line-height:1.3;padding:7px 12px 7px 14px;background:none;border:none;color:var(--text);font:500 14px var(--brand-body);cursor:pointer;transition:background .15s ease,transform .06s ease}-.say-phrase em{font-style:normal;color:var(--accent)}-.say-phrase:hover{background:var(--overlay)}-.say-phrase:active{transform:translateY(1px) scale(.98)}-.say-try{display:inline-flex;align-items:center;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading);cursor:pointer;transition:background .15s ease,color .15s ease}-.say-try:hover{background:var(--overlay);color:var(--accent)}-.say-try:active{transform:translateY(1px)}-.say-phrase:focus-visible,.say-try:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px} .card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}@@ -155,9 +200,15 @@ input:focus-visible{outline:none;border-color:var(--accent) !important} .shot{background:var(--elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden} .shot img{width:100%;display:block} .shot .cap{font-size:11.5px;color:var(--text-2);padding:6px 8px}-#toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999}-.toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)}-.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}+#toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:400}+.toast{background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--teal);+ border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4);+ animation:toastin 180ms ease-out}+@keyframes toastin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}+.toast.out{animation:toastout 140ms ease-in forwards}+@keyframes toastout{to{opacity:0}}+@media (prefers-reduced-motion:reduce){.toast,.toast.out{animation:none}}+.toast.ok{border-left-color:var(--led-green)}.toast.err{border-left-color:var(--led-red)} dialog{margin:auto;background:var(--surface);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw} /* The backdrop law (settings-layout skill): dim 20-30%, NEVER blur, one value for every dialog in the app - settings often change the very thing behind@@ -249,23 +300,31 @@ main a:hover{border-bottom-color:var(--accent)} versions live in the brand popover, NEVER in the bar --> <span class="target-chip" id="targetChip" data-tip="The box this bridge answers on. Switch boxes with the pills under Status."><span class="tdot" id="chipDot"></span><span id="chipName">connecting...</span></span> <div class="actions">- <button class="iconbtn" id="btnHelp" title="Show the getting-started helpers" aria-label="Show the getting-started helpers"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M11 18h2v-2h-2v2m1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></button>- <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><!-- the shared Adom cog, OUTLINE per the 2026-08-24 fleet ruling (mdi:cog-outline; filled is deprecated) --><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 8a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1l-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1l-.07 1l2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18l-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56l.75 1.3l2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04l.75-1.3l-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55l-.75-1.3l-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z"/></svg></button> <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+ <button class="iconbtn" id="btnHelp" aria-pressed="true" aria-label="Hide the prompts panel" data-tip="Hide the prompts panel"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M11 18h2v-2h-2v2m1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></button>+ <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><!-- the shared Adom cog, OUTLINE per the 2026-08-24 fleet ruling (mdi:cog-outline; filled is deprecated) --><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 8a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1l-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1l-.07 1l2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18l-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56l.75 1.3l2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04l.75-1.3l-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55l-.75-1.3l-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z"/></svg></button>+ </div> </header> <main>- <!-- say-strip (canonical, adom-ui-design 1.9.1) + the ONE demo button -->- <section class="say" id="say" aria-labelledby="sayTitle">- <div class="say-head">- <h2 id="sayTitle">Say it</h2>- <p>You never need this page's buttons: just tell your AI what you want, using the word <b class="noun">kicad</b>. Copy a phrase, or send it straight to a new AI thread.</p>- <button class="say-x" id="sayX" aria-label="Collapse" data-tip="Collapse this helper (the ? in the header brings it back)">×</button>+ <!-- newbie-panel (1.22.1): full-width band, SIBLING of the app body. The X+ FULLY closes it (no stub); the header ? is a real toggle for the same state. -->+ <section class="panel" id="panel" aria-labelledby="panelTitle">+ <button class="panel-x" id="panelX" aria-label="Close the prompts panel" data-tip="Close this panel (the ? in the header brings it back)">×</button>+ <h2 id="panelTitle">Say it</h2>+ <p class="explain">You never need this page's buttons: just tell your AI what you want, using the word <b>kicad</b>. Copy a prompt, or send it straight to a new AI thread.</p>+ <div class="panel-cols">+ <div class="panel-left">+ <button class="demo-btn" id="btnDemo" data-tip="A guided six-view tour of your own KiCad, narrated, with synced captions">+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true"><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">6 views, about 2 minutes</div>+ </div>+ <div class="panel-right">+ <table class="prompts"><tbody id="promptRows"></tbody></table>+ </div> </div>- <div class="say-chips" id="sayChips"></div>- <div class="btnrow" style="margin-top:10px"><button class="btn primary" id="btnDemo" data-tip="A guided six-view tour of your own KiCad, narrated, with captions">Play the demo</button></div> </section>- <div class="say-folded" id="sayFolded" style="display:none">Say it · <span id="sayFoldedCount"></span> phrases · tap to expand</div> <!-- status-leds: one compact pill row, prose on tap only --> <div class="leds" id="ledRow" style="margin-top:16px"></div>@@ -456,10 +515,9 @@ const BUSY_KEY='kdBusySince'; function busySince(){try{return JSON.parse(localStorage.getItem(BUSY_KEY))||{}}catch(e){return{}}} function busySave(o){try{localStorage.setItem(BUSY_KEY,JSON.stringify(o))}catch(e){}} function shortState(l){- const d=(l.detail||'').trim();- if(d&&d.length<=30)return d;- if(d)return d.split(/[.;|]/)[0].slice(0,30);- return l.state==='ok'?'ok':(l.state||'unknown');+ // The SERVER emits `short` (#59). The page NEVER truncates prose - that is+ // what produced the fleet's "in talled and pinned".+ return l.short || l.state || 'unknown'; } function renderLeds(st){ const g=$('#ledRow');g.innerHTML='';@@ -525,12 +583,10 @@ $('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);}; $('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background - watch the KiCad LED');await act('launch');}; $('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);}; -// say-strip (canonical, 1.9.1): phrases from dockbar.json `say` via the-// server so the dock interstitial and this strip teach identical words.-// Chip click = copy; Copy = copy; Send to AI = POST api/say-send, which opens-// a NEW AI thread (staged name + dock:kicad-bridge tag) and submits - new-// threads ONLY, resume stays parked. On any failure: error toast + copy, so-// the user is never stranded.+// newbie-panel prompts (1.22.1): a TABLE, not chips. Phrases come from+// dockbar.json `say` via the server, so the dock interstitial and this table+// teach identical words. Whole row copies; the two icon buttons are the+// pack's shipped glyphs inlined (currentColor inherits). function legacyCopy(text){ try{const ta=Object.assign(document.createElement('textarea'),{value:text}); ta.style.cssText='position:fixed;opacity:0';document.body.append(ta);@@ -538,30 +594,39 @@ function legacyCopy(text){ } function copyPhrase(text){ const done=()=>toast('Copied to clipboard. Paste it into any AI thread.','ok');+ // writeText never settles in an UNFOCUSED window, so gate on hasFocus and+ // fall back to an in-gesture execCommand copy (parts-search 1.1.2). if(document.hasFocus()){navigator.clipboard.writeText(text).then(done,()=>{if(legacyCopy(text))done();});} else if(legacyCopy(text))done(); } async function buildSay(){ let phrases=[]; try{const r=await act('say');phrases=((r.data||r).phrases)||[];}catch(e){}- const list=$('#sayChips');list.innerHTML='';- $('#sayFoldedCount').textContent=phrases.length;- if(!phrases.length){$('#say').style.display='none';return;}- for(const p of phrases){- const chip=document.createElement('span');chip.className='say-chip';chip.dataset.say=p;- chip.innerHTML='“'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'”'- +'<button class="copy" data-tip="Copy this phrase">Copy</button>'- +'<button class="send" data-tip="Open a NEW AI thread and send this phrase">Send to AI</button>';- chip.addEventListener('click',()=>copyPhrase(p));- chip.querySelector('.copy').addEventListener('click',e=>{e.stopPropagation();copyPhrase(p);});- chip.querySelector('.send').addEventListener('click',async e=>{- e.stopPropagation();- const r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},- body:JSON.stringify({phrase:p})}).then(x=>x.json()).catch(()=>null);- if(r&&r.ok)toast('Sent to a new AI thread.','ok');- else{copyPhrase(p);toast((r&&r.why)||'Could not reach the AI panel; copied instead.','err');}- });- list.appendChild(chip);+ const body=$('#promptRows');body.innerHTML='';+ if(!phrases.length){$('#panel').style.display='none';return;}+ for(const p of phrases.slice(0,6)){+ const tr=document.createElement('tr');+ const td1=document.createElement('td');td1.className='p-phrase';td1.title=p;+ td1.innerHTML='“'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'”';+ const td2=document.createElement('td');td2.className='p-actions';+ const bc=document.createElement('button');bc.className='ibtn';bc.type='button';+ bc.setAttribute('aria-label','Copy this prompt to your clipboard');+ bc.setAttribute('data-tip','Copy this prompt to your clipboard');bc.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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 bs=document.createElement('button');bs.className='ibtn';bs.type='button';+ bs.setAttribute('aria-label','Send this prompt to a new AI thread');+ bs.setAttribute('data-tip','Send this prompt to a new AI thread');bs.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>';+ tr.onclick=()=>copyPhrase(p);+ bc.onclick=e=>{e.stopPropagation();copyPhrase(p);};+ bs.onclick=async e=>{+ e.stopPropagation();bs.disabled=true;+ try{+ const r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({phrase:p})}).then(x=>x.json()).catch(()=>null);+ if(r&&r.ok)toast('Sent to a new AI thread.','ok');+ else{copyPhrase(p);toast((r&&r.why)||'Could not reach the AI panel; copied instead.','err');}+ }finally{bs.disabled=false;}+ };+ td2.appendChild(bc);td2.appendChild(bs);tr.appendChild(td1);tr.appendChild(td2);body.appendChild(tr); } } // Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat).@@ -901,35 +966,33 @@ const GRAD_KEY='adom-dash-graduation'; function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))||{}}catch(e){return{}}} function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify({...gradState(),...patch}))}catch(e){}} function recalled(){try{return sessionStorage.getItem('adom-dash-recall')==='1'}catch(e){return false}}-const SCAFFOLDS=[- {id:'say', el:'#say', folded:'#sayFolded', threshold:5},- {id:'demos', el:null, folded:null, threshold:3}, // Demos + prompts fold via section helper-];+// newbie-graduation: the panel is either OPEN or FULLY CLOSED - no stub, no+// residual one-liner (retired 1.22.1). The X and the header ? drive the SAME+// state, and the ? is a real TOGGLE (aria-pressed, swapping label/tooltip).+function panelOpen(){+ const g=gradState();+ if(recalled())return true; // ? restored it this session+ if(g.dismissed&&g.dismissed.panel)return false; // explicit close+ if(g.aiDriven)return false; // their AI already drives it+ return (g.launches||0)<3; // three green launches graduates+} function applyGraduation(){- const g=gradState();const fresh=recalled();- const dis=g.dismissed||{};- const launches=g.launches||0;- // say strip: last to fold- const sayFold=!fresh&&(dis.say||g.aiDriven||launches>=5);- $('#say').style.display=sayFold?'none':'';- $('#sayFolded').style.display=sayFold?'':'none';- // demos + prompts sections: fold to their headings at 3- const fold2=!fresh&&(dis.start||g.aiDriven||launches>=3);- foldSection('Demos',fold2);foldSection('Copy-paste prompts for your AI',fold2);+ const open=panelOpen();+ $('#panel').style.display=open?'':'none';+ const h=$('#btnHelp');+ h.setAttribute('aria-pressed',String(open));+ h.setAttribute('aria-label',open?'Hide the prompts panel':'Show the prompts panel');+ h.setAttribute('data-tip',open?'Hide the prompts panel':'Show the prompts panel');+ h.classList.toggle('on',open); }-function foldSection(title,fold){- for(const h of document.querySelectorAll('main h2')){- if(h.textContent.trim().startsWith(title)){- let el=h.nextElementSibling;- h.style.cursor='pointer';h.dataset.tip=fold?'Tap to expand (the ? in the header restores everything)':'';- h.onclick=fold?()=>{let e2=h.nextElementSibling;while(e2&&e2.tagName!=='H2'){e2.style.display='';e2=e2.nextElementSibling;}h.onclick=null;}:null;- while(el&&el.tagName!=='H2'){el.style.display=fold?'none':'';el=el.nextElementSibling;}- }- }+function setPanel(open){+ try{ if(open)sessionStorage.setItem('adom-dash-recall','1');+ else sessionStorage.removeItem('adom-dash-recall'); }catch(e){}+ gradSave({dismissed:{...(gradState().dismissed||{}),panel:!open}});+ applyGraduation(); }-$('#sayX').onclick=()=>{gradSave({dismissed:{...(gradState().dismissed||{}),say:true}});applyGraduation();toast('Collapsed. The ? in the header brings it back.','ok');};-$('#sayFolded').onclick=()=>{$('#say').style.display='';$('#sayFolded').style.display='none';};-$('#btnHelp').onclick=()=>{try{sessionStorage.setItem('adom-dash-recall','1')}catch(e){};applyGraduation();toast('Helpers restored for this session.','ok');};+$('#panelX').onclick=()=>{setPanel(false);toast('Panel closed. The ? in the header brings it back.','ok');};+$('#btnHelp').onclick=()=>setPanel(!panelOpen()); // launch counting: one per page load, when the critical row goes green within 60s let _launchCounted=false;const _loadT=Date.now(); function maybeCountLaunch(st){
dockbar.json+2−3@@ -22,9 +22,8 @@ "console": "show", "steps": [ {- "label": "Installing the KiCad bridge",- "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/kicad-bridge; command -v kicad-dashboard >/dev/null 2>&1 || { adom-wiki pkg uninstall adom/kicad-bridge >/dev/null 2>&1; adom-wiki pkg install adom/kicad-bridge; }'",- "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v kicad-dashboard'",+ "label": "Installing or updating the KiCad bridge",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update kicad-bridge || adom-wiki pkg install adom/kicad-bridge; command -v kicad-dashboard >/dev/null 2>&1 || { adom-wiki pkg uninstall adom/kicad-bridge; adom-wiki pkg install adom/kicad-bridge; }'", "timeout_seconds": 180 }, {
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.77",+ "version": "1.0.78", "type": "app", "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
page.json+1−1@@ -4,7 +4,7 @@ "slug": "kicad-bridge", "title": "KiCad - the KiCad Bridge", "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",- "version": "1.0.77",+ "version": "1.0.78", "tags": [ "kicad", "pcb",