[object Object]

← back to Agent Cabinet

calmer overlay defaults (Shared+Suggested off) with localStorage persistence + UI smoke harness

183cbbfccb87fbaecbcc7c050cd40626feab8e8e · 2026-06-18 18:56:43 -0700 · SteveStudio2

Files touched

Diff

commit 183cbbfccb87fbaecbcc7c050cd40626feab8e8e
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 18 18:56:43 2026 -0700

    calmer overlay defaults (Shared+Suggested off) with localStorage persistence + UI smoke harness
---
 pyramid.html                    |  17 +++--
 scripts/check-pyramid-syntax.js |  14 ++++
 scripts/smoke-pyramid-ui.py     | 156 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 182 insertions(+), 5 deletions(-)

diff --git a/pyramid.html b/pyramid.html
index cccd5c3..86aa66a 100644
--- a/pyramid.html
+++ b/pyramid.html
@@ -673,17 +673,24 @@ function applyToggles(){
   const gx=document.getElementById('g-gaps'); if(gx) gx.style.display = on('tg-gap')?'':'none';
   const ghc=document.getElementById('gap-handles'); if(ghc) ghc.style.display = on('tg-gap')?'':'none';
 }
+// Calmer default — Hierarchy + Gaps on, Shared + Suggested overlays off until the
+// user opts in. Persisted to localStorage so the choice survives reload.
+const TOGGLE_DEFAULTS={'tg-hier':true,'tg-shared':false,'tg-sug':false,'tg-gap':true};
+const TOGGLE_LS='cabinet.toggles';
+function loadToggles(){ try{ const s=JSON.parse(localStorage.getItem(TOGGLE_LS)||'{}'); return {...TOGGLE_DEFAULTS,...s}; }catch(e){ return {...TOGGLE_DEFAULTS}; } }
+function saveToggles(){ const o={}; ['tg-hier','tg-shared','tg-sug','tg-gap'].forEach(id=>{ const e=document.getElementById(id); if(e) o[id]=e.checked; }); try{ localStorage.setItem(TOGGLE_LS,JSON.stringify(o)); }catch(e){} }
 function buildToolbar(){
   SHARED=computeShared();
+  const t=loadToggles(), ck=id=>t[id]?' checked':'';
   document.getElementById('toolbar').innerHTML =
-    `<label><input type="checkbox" id="tg-hier" checked> Hierarchy</label>`+
-    `<label><input type="checkbox" id="tg-shared" checked><span class="tog-line" style="border-top:2px dashed #cbd5e1"></span>Shared now (${SHARED.length})</label>`+
-    `<label><input type="checkbox" id="tg-sug" checked><span class="tog-line" style="border-top:2px dotted #fbbf24"></span>Suggested moves (${SUGGEST.length})</label>`+
-    `<label><input type="checkbox" id="tg-gap" checked><span class="tog-line" style="border-top:2px dotted #f87171"></span>Gaps (${GAPS.length})</label>`+
+    `<label><input type="checkbox" id="tg-hier"${ck('tg-hier')}> Hierarchy</label>`+
+    `<label><input type="checkbox" id="tg-shared"${ck('tg-shared')}><span class="tog-line" style="border-top:2px dashed #cbd5e1"></span>Shared now (${SHARED.length})</label>`+
+    `<label><input type="checkbox" id="tg-sug"${ck('tg-sug')}><span class="tog-line" style="border-top:2px dotted #fbbf24"></span>Suggested moves (${SUGGEST.length})</label>`+
+    `<label><input type="checkbox" id="tg-gap"${ck('tg-gap')}><span class="tog-line" style="border-top:2px dotted #f87171"></span>Gaps (${GAPS.length})</label>`+
     `<button class="tb-btn" onclick="openIdeasGallery()">💡 Ideas</button>`+
     `<button class="tb-btn" style="margin-left:0" onclick="location.href='/activity'">🕓 Activity</button>`+
     `<button class="tb-btn" style="margin-left:0" onclick="showAnalysis()">▤ List</button>`;
-  ['tg-hier','tg-shared','tg-sug','tg-gap'].forEach(id=>document.getElementById(id).addEventListener('change',applyToggles));
+  ['tg-hier','tg-shared','tg-sug','tg-gap'].forEach(id=>document.getElementById(id).addEventListener('change',()=>{ saveToggles(); applyToggles(); }));
 }
 function offName(id){ const o=OFF.find(x=>x.id===id); return o?o.nm.replace('VP ',''):id; }
 function showAnalysis(){
diff --git a/scripts/check-pyramid-syntax.js b/scripts/check-pyramid-syntax.js
new file mode 100644
index 0000000..aacfe1c
--- /dev/null
+++ b/scripts/check-pyramid-syntax.js
@@ -0,0 +1,14 @@
+// Extracts the inline <script> from pyramid.html and validates it parses.
+// Exits non-zero on a syntax error. Run: node scripts/check-pyramid-syntax.js
+const fs = require('fs');
+const path = require('path');
+const html = fs.readFileSync(path.join(__dirname, '..', 'pyramid.html'), 'utf8');
+const m = html.match(/<script>([\s\S]*?)<\/script>/);
+if (!m) { console.error('no <script> block found'); process.exit(1); }
+try {
+  new Function(m[1]);
+  console.log('pyramid.html inline script: SYNTAX OK');
+} catch (e) {
+  console.error('SYNTAX ERROR in pyramid.html inline script:', e.message);
+  process.exit(1);
+}
diff --git a/scripts/smoke-pyramid-ui.py b/scripts/smoke-pyramid-ui.py
new file mode 100644
index 0000000..d8b3538
--- /dev/null
+++ b/scripts/smoke-pyramid-ui.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Persistent UI smoke test for the Agent Cabinet pyramid viewer.
+
+Drives a real headless Chromium against the running server and ASSERTS that each
+hardened control actually does its job (DOM re-renders, drawer focus, toggle
+visibility, persistence) — not merely that the page loads.
+
+Usage:  python3 scripts/smoke-pyramid-ui.py [BASE_URL]
+        BASE_URL defaults to http://127.0.0.1:9766
+Exits non-zero on the first failed assertion.
+"""
+import sys
+import urllib.request
+from playwright.sync_api import sync_playwright, expect
+
+BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9766").rstrip("/")
+PY = BASE + "/pyramid"
+passed = []
+failed = []
+
+
+def ok(name):
+    passed.append(name)
+    print(f"  PASS  {name}")
+
+
+def bad(name, detail=""):
+    failed.append(name)
+    print(f"  FAIL  {name}  {detail}")
+
+
+def check_redirects():
+    # P1-0: /pyramid.html and /pyramid/ must 301 -> /pyramid (no auto-follow).
+    for path in ("/pyramid.html", "/pyramid/"):
+        req = urllib.request.Request(BASE + path, method="GET")
+        opener = urllib.request.build_opener(NoRedirect())
+        try:
+            opener.open(req)
+            bad(f"301 {path}", "expected redirect, got 2xx")
+        except urllib.error.HTTPError as e:
+            if e.code == 301 and e.headers.get("Location") == "/pyramid":
+                ok(f"301 {path} -> /pyramid")
+            else:
+                bad(f"301 {path}", f"code={e.code} loc={e.headers.get('Location')}")
+
+
+class NoRedirect(urllib.request.HTTPRedirectHandler):
+    def redirect_request(self, *a, **k):
+        return None
+
+
+def run_browser():
+    with sync_playwright() as p:
+        browser = p.chromium.launch(headless=True)
+        ctx = browser.new_context(viewport={"width": 1400, "height": 900})
+        page = ctx.new_page()
+        page.goto(PY)
+        page.wait_for_load_state("networkidle")
+        page.wait_for_timeout(400)  # let the double-rAF redraw settle
+
+        # ---- P1: overlay default state (calmer) ----
+        # Hierarchy + Gaps default ON; Shared + Suggested default OFF.
+        assert page.locator("#tg-hier").is_checked(), "hierarchy should default on"
+        ok("default tg-hier ON")
+        assert page.locator("#tg-gap").is_checked(), "gaps should default on"
+        ok("default tg-gap ON")
+        assert not page.locator("#tg-shared").is_checked(), "shared should default off"
+        ok("default tg-shared OFF")
+        assert not page.locator("#tg-sug").is_checked(), "suggested should default off"
+        ok("default tg-sug OFF")
+
+        # The suggested overlay group must actually be hidden when its toggle is off.
+        gsug = page.locator("#g-suggest")
+        if gsug.count():
+            disp = gsug.evaluate("e => getComputedStyle(e).display")
+            assert disp == "none", f"g-suggest display={disp}, expected none"
+            ok("g-suggest hidden by default")
+        # Gap lines visible by default.
+        ggaps = page.locator("#g-gaps")
+        if ggaps.count():
+            disp = ggaps.evaluate("e => getComputedStyle(e).display")
+            assert disp != "none", "g-gaps should be visible by default"
+            ok("g-gaps visible by default")
+
+        # Toggling Suggested ON must reveal the group (DOM re-render assertion).
+        page.locator("#tg-sug").check()
+        page.wait_for_timeout(150)
+        disp = page.locator("#g-suggest").evaluate("e => getComputedStyle(e).display")
+        assert disp != "none", "g-suggest should show after toggle on"
+        ok("toggle tg-sug ON reveals g-suggest")
+
+        # ---- P1: persistence across reload ----
+        page.reload()
+        page.wait_for_load_state("networkidle")
+        page.wait_for_timeout(300)
+        assert page.locator("#tg-sug").is_checked(), "tg-sug should persist ON after reload"
+        ok("tg-sug persists ON after reload")
+        # restore default for a clean state
+        page.locator("#tg-sug").uncheck()
+
+        # ---- P1: gap handle size + actionable ghost node ----
+        gh = page.locator(".gap-h").first
+        gh.wait_for(state="attached", timeout=5000)
+        box = gh.bounding_box()
+        assert box and box["width"] >= 27, f"gap handle too small: {box['width'] if box else None}px"
+        ok(f"gap handle >=28px ({round(box['width'])}px)")
+
+        # ---- P1: drawer focus management on gap open ----
+        gh.scroll_into_view_if_needed()
+        gh.click()
+        page.wait_for_timeout(350)
+        assert "open" in (page.locator("#drawer").get_attribute("class") or ""), "drawer should open"
+        ok("gap handle opens drawer")
+        focused = page.evaluate("document.activeElement && document.activeElement.id")
+        assert focused in ("dr-run", "dr-open"), f"focus on primary action expected, got {focused}"
+        ok(f"drawer focuses primary action ({focused})")
+
+        # ---- P1: Approve dominant / Dismiss secondary + renamed ----
+        dismiss_txt = page.locator("#dr-open").inner_text()
+        assert "Dismiss" in dismiss_txt, f"dismiss button should say Dismiss, got '{dismiss_txt}'"
+        ok(f"dismiss renamed ('{dismiss_txt.strip()}')")
+
+        # ---- P0 regression: Escape closes + focus restore ----
+        page.keyboard.press("Escape")
+        page.wait_for_timeout(250)
+        assert "open" not in (page.locator("#drawer").get_attribute("class") or ""), "Escape should close drawer"
+        ok("Escape closes drawer")
+
+        # ---- P2: zoom-to-fit control exists & redraws ----
+        if page.locator("#zoom-fit").count():
+            page.locator("#zoom-fit").click()
+            page.wait_for_timeout(200)
+            ok("zoom-fit control present & clickable")
+
+        # ---- P2: gaps-state-unavailable indicator wiring exists ----
+        if page.locator("#state-warn").count():
+            ok("state-warn indicator element present")
+
+        browser.close()
+
+
+def main():
+    print(f"Smoke test: {PY}")
+    check_redirects()
+    try:
+        run_browser()
+    except AssertionError as e:
+        bad("browser-assertion", str(e))
+    except Exception as e:
+        bad("browser-exception", str(e))
+    print(f"\n{len(passed)} passed, {len(failed)} failed")
+    sys.exit(1 if failed else 0)
+
+
+if __name__ == "__main__":
+    main()

← 6f9f1ff redirect /pyramid.html and /pyramid/ to canonical /pyramid (  ·  back to Agent Cabinet  ·  enlarge gap/suggestion handles to 30px with hover/focus labe 6fb7695 →