← Commit history

Publish 1.0.78

John Lauer ·ed64f0ac60 ·1mo ago ·parent e54a4d4
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)">&times;</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)">&times;</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 &middot; <span id="sayFoldedCount"></span> phrases &middot; 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='&ldquo;'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'&rdquo;'-      +'<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='&ldquo;'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'&rdquo;';+    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",