@enigmax/dashboard 0.1.96 → 0.1.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/assets/index.html +494 -28
  2. package/package.json +1 -1
package/assets/index.html CHANGED
@@ -87,12 +87,27 @@
87
87
  /* Sections as a vertical rail: the sidebar is already the surface, so the active
88
88
  entry is a filled pill and the rest are plain rows. */
89
89
  .tabs { display: flex; flex-direction: column; gap: 2px; }
90
+ /* Twelve flat entries were a wall of similar words, so they are grouped and iconified
91
+ (frontend-policy, "Navigation Is Structured"). The group label is a quiet caption,
92
+ not another bordered box - the rail is already the surface. */
93
+ .nav-group { display: flex; flex-direction: column; gap: 2px; }
94
+ .nav-group[hidden] { display: none; }
95
+ .nav-group + .nav-group { margin-top: 13px; }
96
+ .nav-group.nav-tail { margin-top: 20px; }
97
+ .nav-group-t {
98
+ font-family: var(--mono); font-size: 9.5px; font-weight: 600; text-transform: uppercase;
99
+ letter-spacing: 1.5px; color: var(--muted); opacity: .6; padding: 0 12px; margin-bottom: 3px;
100
+ }
90
101
  .tabs .tab {
102
+ display: flex; align-items: center; gap: 9px;
91
103
  font-family: var(--mono); text-decoration: none; color: var(--muted); font-size: 12.5px; font-weight: 600;
92
104
  padding: 8px 12px; border-radius: 9px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s;
93
105
  }
106
+ /* The label carries the meaning here (unlike an icon action), so the glyph is decorative. */
107
+ .tab-ic { width: 15px; height: 15px; flex: 0 0 15px; opacity: .8; }
94
108
  .tabs .tab:hover { color: var(--text); background: rgba(255,255,255,0.03); }
95
109
  .tabs .tab.active { color: var(--accent); background: var(--surface2); border-color: var(--border); }
110
+ .tabs .tab.active .tab-ic { opacity: 1; }
96
111
  .live { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; font-family: var(--mono); padding: 5px 12px; background: rgba(0,0,0,0.25); border: 1px solid var(--border-soft); border-radius: 999px; }
97
112
  .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--good); box-shadow: 0 0 8px var(--good); }
98
113
  .dot.stale { background: var(--muted); box-shadow: none; }
@@ -564,6 +579,51 @@
564
579
  }
565
580
  .modal-back.show .modal { transform: none; opacity: 1; }
566
581
  .modal.wide { max-width: 900px; }
582
+ /* Command palette: the same backdrop and surface as every other dialog, sat in the top
583
+ third so the result list grows downwards without moving the input under the caret. */
584
+ /* The palette lives in the markup instead of being created per open, so it needs this:
585
+ `display: flex` above beats the UA's `[hidden] { display: none }` on specificity, and a
586
+ closed backdrop would otherwise sit invisibly over the page eating every click. */
587
+ .modal-back[hidden] { display: none; }
588
+ .modal-back.pal-back { align-items: flex-start; padding-top: 12vh; }
589
+ .modal.pal { max-width: 520px; padding: 12px; }
590
+ .pal-input {
591
+ width: 100%; background: transparent; color: var(--text); border: none; border-bottom: 1px solid var(--border);
592
+ padding: 8px 6px 12px; font-size: 14px; font-family: var(--mono);
593
+ }
594
+ .pal-input:focus { outline: none; }
595
+ .pal-input::-webkit-search-cancel-button { display: none; }
596
+ .pal-list { max-height: 46vh; overflow-y: auto; overscroll-behavior: contain; margin-top: 8px; }
597
+ .pal-gt {
598
+ font-family: var(--mono); font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.4px;
599
+ color: var(--muted); opacity: .6; padding: 10px 8px 4px;
600
+ }
601
+ .pal-row {
602
+ display: flex; align-items: center; gap: 10px; padding: 8px 8px; border-radius: 8px;
603
+ cursor: pointer; color: var(--muted); font-size: 13px;
604
+ }
605
+ .pal-row .tab-ic { opacity: .75; }
606
+ .pal-row .pal-name { font-family: var(--mono); font-weight: 600; color: var(--text); }
607
+ .pal-row .pal-hint { margin-left: auto; font-family: var(--mono); font-size: 11px; opacity: .6; }
608
+ .pal-row.sel { background: var(--surface2); color: var(--text); }
609
+ .pal-row.sel .tab-ic { opacity: 1; color: var(--accent); }
610
+ .pal-empty { padding: 18px 8px; color: var(--muted); font-size: 13px; }
611
+ .pal-foot {
612
+ display: flex; gap: 14px; padding: 10px 8px 2px; border-top: 1px solid var(--border-soft);
613
+ font-family: var(--mono); font-size: 10.5px; color: var(--muted); opacity: .7;
614
+ }
615
+ .kbd {
616
+ font-family: var(--mono); font-size: 10px; padding: 1px 5px; border: 1px solid var(--border);
617
+ border-radius: 4px; background: var(--surface2);
618
+ }
619
+ /* The shortcut has to be discoverable, so the palette also has a clickable entry point. */
620
+ .cmdk {
621
+ display: inline-flex; align-items: center; gap: 8px; cursor: pointer; color: var(--muted);
622
+ background: rgba(0,0,0,0.25); border: 1px solid var(--border-soft); border-radius: 999px;
623
+ padding: 5px 8px 5px 12px; font-family: var(--mono); font-size: 12px;
624
+ }
625
+ .cmdk:hover { color: var(--text); border-color: var(--border); }
626
+ .cmdk svg { width: 13px; height: 13px; }
567
627
  .as-top { display: flex; gap: 8px; margin: 14px 0 6px; }
568
628
  .as-list { max-height: 50vh; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 4px 12px; }
569
629
  .as-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--surface2); cursor: pointer; }
@@ -646,18 +706,68 @@
646
706
  </button>
647
707
  </div>
648
708
  <nav class="tabs">
649
- <a href="#/" data-view="savings" class="tab active">Savings</a>
650
- <a href="#/usage" data-view="usage" class="tab">Usage</a>
651
- <a href="#/accounts" data-view="accounts" class="tab">Accounts</a>
652
- <a href="#/skills" data-view="skills" class="tab">Skills</a>
653
- <a href="#/settings" data-view="settings" class="tab">Settings</a>
654
- <a href="#/gate" data-view="gate" class="tab">Quality gate</a>
655
- <a href="#/resources" data-view="resources" class="tab">Resources</a>
656
- <a href="#/recall" data-view="recall" class="tab">Recall</a>
657
- <a href="#/codegraph" data-view="codegraph" class="tab">Code graph</a>
658
- <a href="#/playground" data-view="playground" class="tab">Playground</a>
659
- <a href="#/packs" data-view="packs" class="tab">Packs</a>
660
- <a href="#/ssh" data-view="ssh" class="tab">SSH</a>
709
+ <div class="nav-group" role="group" aria-labelledby="ng-overview">
710
+ <div class="nav-group-t" id="ng-overview">Overview</div>
711
+ <a href="#/" data-view="savings" class="tab active">
712
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
713
+ <span>Savings</span>
714
+ </a>
715
+ <a href="#/usage" data-view="usage" class="tab">
716
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
717
+ <span>Usage</span>
718
+ </a>
719
+ </div>
720
+ <div class="nav-group" role="group" aria-labelledby="ng-agents">
721
+ <div class="nav-group-t" id="ng-agents">Agents</div>
722
+ <a href="#/accounts" data-view="accounts" class="tab">
723
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
724
+ <span>Accounts</span>
725
+ </a>
726
+ <a href="#/skills" data-view="skills" class="tab">
727
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/></svg>
728
+ <span>Skills</span>
729
+ </a>
730
+ <a href="#/packs" data-view="packs" class="tab">
731
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
732
+ <span>Packs</span>
733
+ </a>
734
+ <a href="#/playground" data-view="playground" class="tab">
735
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>
736
+ <span>Playground</span>
737
+ </a>
738
+ </div>
739
+ <div class="nav-group" role="group" aria-labelledby="ng-code">
740
+ <div class="nav-group-t" id="ng-code">Code</div>
741
+ <a href="#/gate" data-view="gate" class="tab">
742
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/></svg>
743
+ <span>Quality gate</span>
744
+ </a>
745
+ <a href="#/codegraph" data-view="codegraph" class="tab">
746
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4"/><path d="m15.4 6.5-6.8 4"/></svg>
747
+ <span>Code graph</span>
748
+ </a>
749
+ <a href="#/recall" data-view="recall" class="tab">
750
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
751
+ <span>Recall</span>
752
+ </a>
753
+ </div>
754
+ <div class="nav-group" role="group" aria-labelledby="ng-system">
755
+ <div class="nav-group-t" id="ng-system">System</div>
756
+ <a href="#/resources" data-view="resources" class="tab">
757
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>
758
+ <span>Resources</span>
759
+ </a>
760
+ <a href="#/ssh" data-view="ssh" class="tab">
761
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg>
762
+ <span>SSH</span>
763
+ </a>
764
+ </div>
765
+ <div class="nav-group nav-tail">
766
+ <a href="#/settings" data-view="settings" class="tab">
767
+ <svg class="tab-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/></svg>
768
+ <span>Settings</span>
769
+ </a>
770
+ </div>
661
771
  </nav>
662
772
  <div class="side-foot">
663
773
  <nav class="links">
@@ -674,12 +784,30 @@
674
784
  </div>
675
785
  </aside>
676
786
  <div id="navScrim" class="nav-scrim" hidden></div>
787
+ <div id="palBack" class="modal-back pal-back" hidden>
788
+ <div class="modal pal" role="dialog" aria-modal="true" aria-label="Search views and projects">
789
+ <input id="palInput" class="pal-input" type="search" role="combobox" aria-expanded="true"
790
+ aria-controls="palList" aria-autocomplete="list" placeholder="Jump to a view or a project"
791
+ autocomplete="off" autocapitalize="none" spellcheck="false" enterkeyhint="go">
792
+ <div id="palList" class="pal-list" role="listbox" aria-label="Results"></div>
793
+ <div class="pal-foot">
794
+ <span><span class="kbd">up</span> <span class="kbd">down</span> move</span>
795
+ <span><span class="kbd">enter</span> open</span>
796
+ <span><span class="kbd">esc</span> close</span>
797
+ </div>
798
+ </div>
799
+ </div>
677
800
  <div class="content">
678
801
  <header>
679
802
  <button type="button" id="navOpen" class="iconbtn nav-burger" title="Menu" aria-label="Open the menu" aria-expanded="false" aria-controls="sidebar">
680
803
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
681
804
  </button>
682
805
  <div class="nav">
806
+ <button type="button" id="palOpen" class="cmdk" aria-label="Search views and projects" aria-haspopup="dialog">
807
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
808
+ <span>Search</span>
809
+ <span class="kbd" id="palHint">Ctrl K</span>
810
+ </button>
683
811
  <label class="scope-wrap" title="Scope: global config or a specific project"><span class="scope-lbl">Scope</span><select id="scopeSel" class="scope-sel"><option value="global">Global</option></select></label>
684
812
  <span class="live"><span id="dot" class="dot stale"></span><span id="updated">Connecting...</span></span>
685
813
  </div>
@@ -935,19 +1063,55 @@
935
1063
  <button id="gateRefresh" type="button" class="iconbtn" title="Refresh" aria-label="Refresh the gate view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
936
1064
  </div>
937
1065
  <div class="grid">
938
- <div class="card"><div class="label">Gate</div><div id="gateOn" class="value">-</div><div class="foot">Auto-driven after a code task</div></div>
939
- <div class="card"><div class="label">Daemon</div><div id="gateDaemon" class="value">-</div><div class="foot">Runs the pipeline in the background</div></div>
1066
+ <div class="card">
1067
+ <div class="label">Gate</div><div id="gateOn" class="value">-</div>
1068
+ <div class="foot" style="display:flex;align-items:center;gap:8px">Auto-driven after a code task<button id="gateOnBtn" type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" aria-label="AI quality gate"></button></div>
1069
+ </div>
1070
+ <div class="card">
1071
+ <div class="label">Daemon</div><div id="gateDaemon" class="value">-</div>
1072
+ <div class="foot" style="display:flex;align-items:center;gap:8px">Runs the pipeline in the background<button id="gateDaemonBtn" type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" aria-label="Gate daemon"></button></div>
1073
+ </div>
940
1074
  <div class="card"><div class="label">Repository</div><div id="gateRepo" class="value">-</div><div id="gateRepoFoot" class="foot">Scope</div></div>
941
1075
  </div>
942
1076
 
943
1077
  <div class="panel">
944
- <div class="panel-head"><h2>Runs <small id="gateRunsSub">most recent first</small></h2></div>
1078
+ <div class="panel-head"><h2>Runs <small id="gateRunsSub">most recent first</small></h2><span id="gateLive" class="set-hint"></span></div>
945
1079
  <div id="gateRuns"><div class="empty" style="padding:12px 0">Loading...</div></div>
946
1080
  </div>
947
1081
 
1082
+ <div class="panel" id="gateSetPanel">
1083
+ <div class="set-cat-h">Pipeline settings <code id="gateCfgPath"></code></div>
1084
+ <div class="sub" style="margin-bottom:10px">What the daemon runs and how hard it tries. Model and auto-fix limits are what a run's duration and token cost actually hang on.</div>
1085
+ <div class="set-grid">
1086
+ <div class="set-row">
1087
+ <div class="set-meta"><div class="set-name">Agent</div><div class="set-hint" id="gateAgentHint">which coding agent the pipeline drives</div></div>
1088
+ <div class="set-ctl"><select id="gateAgent" class="linput"><option value="auto">auto</option><option value="claude">claude</option><option value="codex">codex</option><option value="opencode">opencode</option><option value="rovodev">rovodev</option><option value="pi">pi</option></select></div>
1089
+ </div>
1090
+ <div class="set-row" id="gateModelRow">
1091
+ <div class="set-meta"><div class="set-name">Model</div><div class="set-hint" id="gateModelHint">the model every step runs on; a smaller one cuts run time and tokens</div></div>
1092
+ <div class="set-ctl ladd" style="align-items:center"><input id="gateModel" class="linput" style="flex:none;width:210px" spellcheck="false" autocapitalize="none" autocorrect="off" placeholder="agent default"><button id="gateModelSave" type="button" class="toggle on">Save</button></div>
1093
+ </div>
1094
+ <div class="set-row">
1095
+ <div class="set-meta"><div class="set-name">CI timeout</div><div class="set-hint">how long the CI step babysits an idle PR: 168h, 24h, or unlimited</div></div>
1096
+ <div class="set-ctl ladd" style="align-items:center"><input id="gateCiTimeout" class="linput" style="flex:none;width:120px" spellcheck="false" autocapitalize="none" autocorrect="off" placeholder="168h"><button id="gateCiSave" type="button" class="toggle on">Save</button></div>
1097
+ </div>
1098
+ <div class="set-row">
1099
+ <div class="set-meta"><div class="set-name">Log level</div><div class="set-hint">daemon output verbosity</div></div>
1100
+ <div class="set-ctl"><select id="gateLogLevel" class="linput"><option value="debug">debug</option><option value="info">info</option><option value="warn">warn</option><option value="error">error</option></select></div>
1101
+ </div>
1102
+ <div class="set-row">
1103
+ <div class="set-meta"><div class="set-name">Read intent from transcripts</div><div class="set-hint">feeds what you were trying to do to every step; off makes the agents work from the diff alone</div></div>
1104
+ <div class="set-ctl"><button id="gateIntent" type="button" class="toggle" data-kind="bool" role="switch" aria-checked="false" aria-label="Read intent from transcripts"></button></div>
1105
+ </div>
1106
+ </div>
1107
+ <div class="set-cat-h" style="margin-top:18px">Auto-fix attempts</div>
1108
+ <div class="sub" style="margin-bottom:10px">Follow-up rounds a step gets before it pauses for you. Every round is another full agent pass over the diff, so this is the setting that multiplies a run's cost. 0 pauses immediately.</div>
1109
+ <div class="set-grid" id="gateAutoFix"></div>
1110
+ </div>
1111
+
948
1112
  <div class="panel">
949
- <div class="set-cat-h">Global configuration <code id="gateCfgPath"></code></div>
950
- <div class="sub" style="margin-bottom:10px">Agent, CI timeout, log level, per-step commands and auto-fix limits. Saved only when it parses, so a typo here never reaches the daemon.</div>
1113
+ <div class="set-cat-h">Global configuration <code class="gate-cfg-path"></code></div>
1114
+ <div class="sub" style="margin-bottom:10px">The same file as raw YAML, for the keys with no control above (agent paths, ACP targets, ignore patterns, evidence). Saved only when it parses, so a typo here never reaches the daemon.</div>
951
1115
  <textarea id="gateCfg" class="linput gate-yaml" spellcheck="false" autocapitalize="none" autocorrect="off" aria-label="Global gate configuration"></textarea>
952
1116
  <div class="ladd" style="gap:8px;align-items:center">
953
1117
  <button id="gateCfgSave" type="button" class="toggle on" disabled title="No changes to save">Save</button>
@@ -4158,12 +4322,29 @@ export {
4158
4322
  if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
4159
4323
  return Math.round(ms / 60000) + "m " + Math.round((ms % 60000) / 1000) + "s";
4160
4324
  }
4325
+ // Server-minus-browser clock offset, so a step's live elapsed is measured against the
4326
+ // clock that stamped started_at rather than against this machine's.
4327
+ let gateClockSkew = 0;
4328
+ // A step only gets its duration written when a round ends, so a step that is running or
4329
+ // in a fix round would otherwise freeze at its last round. Tick it from started_at instead.
4330
+ const GATE_ACTIVE_STEP = { running: 1, fixing: 1, awaiting_approval: 1, fix_review: 1 };
4331
+ function gateStepDur(s) {
4332
+ if (GATE_ACTIVE_STEP[s.status] && s.startedAt) {
4333
+ return gateDur(Math.max(0, Date.now() + gateClockSkew - s.startedAt * 1000));
4334
+ }
4335
+ return s.durationMs ? gateDur(s.durationMs) : "";
4336
+ }
4337
+ function gateStepsHtml(steps) {
4338
+ return (steps || []).map((s) => {
4339
+ const d = gateStepDur(s);
4340
+ return '<span class="gate-step ' + esc(s.status) + '">' + esc(s.name)
4341
+ + (d ? " · " + esc(d) : "")
4342
+ + (s.findings ? " · " + s.findings : "") + "</span>";
4343
+ }).join("");
4344
+ }
4161
4345
  function gateRunRow(r) {
4162
4346
  const pr = r.prUrl ? ' <a href="' + esc(r.prUrl) + '" target="_blank" rel="noopener noreferrer">PR</a>' : "";
4163
- const steps = (r.steps || []).map((s) =>
4164
- '<span class="gate-step ' + esc(s.status) + '">' + esc(s.name)
4165
- + (s.durationMs ? " · " + esc(gateDur(s.durationMs)) : "")
4166
- + (s.findings ? " · " + s.findings : "") + "</span>").join("");
4347
+ const steps = gateStepsHtml(r.steps);
4167
4348
  const findings = (r.findings || []).length
4168
4349
  ? '<div class="gate-find">' + r.findings.map((f) =>
4169
4350
  '<div class="gate-find-row"><b>' + esc(f.id) + "</b> · " + esc(f.severity) + " · " + esc(f.action)
@@ -4176,21 +4357,78 @@ export {
4176
4357
  + '<span class="gate-run-meta">' + esc(r.headSha) + pr + "</span>"
4177
4358
  + "</div>" + '<div class="gate-steps">' + steps + "</div>" + findings + err + "</div>";
4178
4359
  }
4179
- function renderGate(g) {
4360
+ // The two on/off switches at the top of the page. Both are optimistic: the switch flips
4361
+ // first and rolls back if the server refuses, like every other control here.
4362
+ function gateBool(btn, on) {
4363
+ btn.textContent = on ? "on" : "off";
4364
+ btn.classList.toggle("on", !!on);
4365
+ btn.dataset.on = on ? "1" : "0";
4366
+ btn.setAttribute("aria-checked", on ? "true" : "false");
4367
+ }
4368
+ const GATE_AUTOFIX = [
4369
+ ["rebase", "conflicts and stale-base failures"],
4370
+ ["review", "0 by default: review findings are yours to approve"],
4371
+ ["test", "failing tests"],
4372
+ ["document", "gaps the initial documentation pass left"],
4373
+ ["lint", "lint and formatting failures"],
4374
+ ["ci", "CI failures and merge conflicts on the open PR"]
4375
+ ];
4376
+ // Rendered once per non-live load; the number inputs write on change, so a re-render
4377
+ // mid-edit would drop what is being typed.
4378
+ function renderGateSettings(s) {
4379
+ const panel = $("gateSetPanel");
4380
+ if (!s) { panel.style.display = "none"; return; }
4381
+ panel.style.display = "";
4382
+ // An `acp:<target>` agent set from the raw editor is not one of the fixed options;
4383
+ // carry it as one so the select shows the truth instead of going blank.
4384
+ const agentSel = $("gateAgent");
4385
+ if (!Array.prototype.some.call(agentSel.options, (o) => o.value === s.agent)) {
4386
+ agentSel.add(new Option(s.agent, s.agent));
4387
+ }
4388
+ agentSel.value = s.agent;
4389
+ syncDD(agentSel);
4390
+ $("gateAgentHint").textContent = s.agent === "auto"
4391
+ ? "auto: first agent found on PATH - currently " + s.agentResolved
4392
+ : "which coding agent the pipeline drives";
4393
+ $("gateModelRow").style.display = s.modelSupported ? "" : "none";
4394
+ $("gateModel").value = s.model || "";
4395
+ $("gateModelHint").textContent = "passed to " + s.agentResolved
4396
+ + " on every step; a smaller model is the one change that cuts both run time and tokens";
4397
+ $("gateCiTimeout").value = s.ciTimeout || "";
4398
+ $("gateLogLevel").value = s.logLevel;
4399
+ syncDD($("gateLogLevel"));
4400
+ gateBool($("gateIntent"), s.intentEnabled);
4401
+ $("gateAutoFix").innerHTML = GATE_AUTOFIX.map(([step, hint]) =>
4402
+ '<div class="set-row"><div class="set-meta"><div class="set-name">' + esc(step) + '</div><div class="set-hint">' + esc(hint) + "</div></div>"
4403
+ + '<div class="set-ctl"><input type="number" min="0" max="20" step="1" inputmode="numeric" class="linput" style="flex:none;width:80px" data-gate-af="' + esc(step) + '" value="' + Number(s.autoFix[step] || 0) + '"></div></div>').join("");
4404
+ const ctls = panel.querySelectorAll("input, select, button");
4405
+ for (const c of ctls) c.disabled = !gateWritable;
4406
+ }
4407
+ // `live` marks a poll refresh: it repaints the run strip and the two switches, and leaves
4408
+ // every editor alone so a poll can never overwrite what is being typed.
4409
+ function renderGate(g, live) {
4180
4410
  g = g || {};
4411
+ if (typeof g.serverNow === "number") gateClockSkew = g.serverNow * 1000 - Date.now();
4181
4412
  $("gateOn").textContent = g.on ? "On" : "Off";
4182
4413
  $("gateOn").className = "value " + (g.on ? "good" : "");
4414
+ gateBool($("gateOnBtn"), g.on);
4183
4415
  $("gateDaemon").textContent = g.daemon ? "Running" : "Stopped";
4184
4416
  $("gateDaemon").className = "value " + (g.daemon ? "good" : "");
4417
+ gateBool($("gateDaemonBtn"), g.daemon);
4185
4418
  const repo = g.repo;
4186
4419
  $("gateRepo").textContent = repo ? (repo.initialized ? "Initialized" : "Not initialized") : "Global";
4187
4420
  $("gateRepoFoot").textContent = repo ? repo.path : "Every active run on this machine";
4188
4421
  $("gateRunsSub").textContent = repo ? "most recent first, this repository" : "active runs across every repository";
4189
4422
  const runs = g.runs || [];
4423
+ gateRuns = runs;
4190
4424
  $("gateRuns").innerHTML = runs.length
4191
4425
  ? runs.map(gateRunRow).join("")
4192
4426
  : '<div class="empty" style="padding:12px 0">' + (g.runsAvailable === false ? esc(g.runsNote || "Run history unavailable.") : (repo ? "No runs recorded for this repository yet." : "No run in flight.")) + "</div>";
4193
- $("gateCfgPath").textContent = (g.globalConfig || {}).path || "";
4427
+ gatePoll();
4428
+ if (live) return;
4429
+ const cfgPath = (g.globalConfig || {}).path || "";
4430
+ $("gateCfgPath").textContent = cfgPath;
4431
+ for (const el of document.querySelectorAll(".gate-cfg-path")) el.textContent = cfgPath;
4194
4432
  gateSaved.global = (g.globalConfig || {}).text || "";
4195
4433
  $("gateCfg").value = gateSaved.global;
4196
4434
  // A runtime that cannot parse a gate config must not write one: read-only, with the reason.
@@ -4199,6 +4437,7 @@ export {
4199
4437
  $("gateRepoCfg").readOnly = !gateWritable;
4200
4438
  if (!gateWritable) { setNote("gateCfgNote", g.writeNote || "Read-only here.", "err"); setNote("gateRepoCfgNote", g.writeNote || "Read-only here.", "err"); }
4201
4439
  syncGateSave("global");
4440
+ renderGateSettings(g.settings);
4202
4441
  $("gateRepoPanel").style.display = repo ? "" : "none";
4203
4442
  if (repo) {
4204
4443
  $("gateRepoCfgPath").textContent = repo.configPath || "";
@@ -4207,18 +4446,48 @@ export {
4207
4446
  syncGateSave("repo");
4208
4447
  }
4209
4448
  }
4210
- async function loadGate() {
4449
+
4450
+ // --- keeping the run strip live -------------------------------------------------
4451
+ // A gate run takes tens of minutes and the page has no push channel, so without this the
4452
+ // strip shows whatever was true when the page was opened. Two loops: a 5s refetch while
4453
+ // something is in flight (20s when idle) and a 1s repaint so the active step's elapsed
4454
+ // counts up between fetches. Both stop when the tab is hidden or the view is not open.
4455
+ let gateRuns = [], gateFetchTimer = null, gateTickTimer = null;
4456
+ function gateBusy() {
4457
+ return gateRuns.some((r) => r.status === "running" || r.status === "awaiting_approval");
4458
+ }
4459
+ function gateStopPoll() {
4460
+ clearTimeout(gateFetchTimer); gateFetchTimer = null;
4461
+ clearInterval(gateTickTimer); gateTickTimer = null;
4462
+ $("gateLive").textContent = "";
4463
+ }
4464
+ function gatePoll() {
4465
+ gateStopPoll();
4466
+ if (activeView !== "gate" || document.hidden) return;
4467
+ const busy = gateBusy();
4468
+ $("gateLive").textContent = busy ? "live" : "";
4469
+ gateFetchTimer = setTimeout(() => loadGate(true), busy ? 5000 : 20000);
4470
+ if (!busy) return;
4471
+ // Repaint only the step strips: replacing whole rows every second would drop a text
4472
+ // selection inside the findings of a parked run.
4473
+ gateTickTimer = setInterval(() => {
4474
+ const strips = $("gateRuns").querySelectorAll(".gate-steps");
4475
+ strips.forEach((el, i) => { if (gateRuns[i]) el.innerHTML = gateStepsHtml(gateRuns[i].steps); });
4476
+ }, 1000);
4477
+ }
4478
+ async function loadGate(live) {
4211
4479
  const q = isProjectScope() ? "?path=" + encodeURIComponent(currentScope) : "";
4212
- const note = (msg) => { $("gateRuns").innerHTML = '<div class="empty" style="padding:12px 0">' + msg + "</div>"; };
4480
+ const note = (msg) => { gateStopPoll(); $("gateRuns").innerHTML = '<div class="empty" style="padding:12px 0">' + msg + "</div>"; };
4213
4481
  try {
4214
4482
  const res = await fetch("/api/gate" + q, { cache: "no-store" });
4215
4483
  // The UI bundle updates on its own; the route it calls ships with the CLI, so a
4216
4484
  // 404 here means the running server predates this view rather than being down.
4217
4485
  if (res.status === 404 || res.status === 405) { note("This view needs a newer enigma CLI: the running server has no /api/gate route yet. Run <code>enigma update</code> once the next release ships."); return; }
4218
4486
  if (!res.ok) { note("The server refused the request (" + res.status + ")."); return; }
4219
- renderGate(await res.json());
4487
+ renderGate(await res.json(), live);
4220
4488
  } catch { note("Could not reach the server."); }
4221
4489
  }
4490
+ document.addEventListener("visibilitychange", () => { if (activeView === "gate") { if (document.hidden) gateStopPoll(); else loadGate(true); } });
4222
4491
  async function saveGateCfg(which) {
4223
4492
  const { area, note } = gateEls(which);
4224
4493
  if (area.value === gateSaved[which]) return; // no-op: nothing changed
@@ -4231,11 +4500,52 @@ export {
4231
4500
  if (out.ok) { gateSaved[which] = area.value; syncGateSave(which); }
4232
4501
  } catch { setNote(note, "Could not reach the server.", "err"); }
4233
4502
  }
4234
- $("gateRefresh").addEventListener("click", loadGate);
4503
+ /** One form control -> one key of the global config. Reloads so the file stays the truth. */
4504
+ async function saveGateSetting(key, value, label) {
4505
+ setNote("gateCfgNote", "Saving " + label + "...");
4506
+ try {
4507
+ const out = await (await fetch("/api/gate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "setting", key, value }) })).json();
4508
+ setNote("gateCfgNote", out.message || (out.ok ? "Saved." : "Not saved."), out.ok ? "ok" : "err");
4509
+ await loadGate();
4510
+ } catch { setNote("gateCfgNote", "Could not reach the server.", "err"); await loadGate(); }
4511
+ }
4512
+ async function gateDaemonPost(on) {
4513
+ gateBool($("gateDaemonBtn"), on); // optimistic; the reload below is the truth
4514
+ setNote("gateCfgNote", on ? "Starting the daemon..." : "Stopping the daemon...");
4515
+ try {
4516
+ const out = await (await fetch("/api/gate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "daemon", on }) })).json();
4517
+ setNote("gateCfgNote", out.message || (out.ok ? "Done." : "Failed."), out.ok ? "ok" : "err");
4518
+ } catch { setNote("gateCfgNote", "Could not reach the server.", "err"); }
4519
+ await loadGate();
4520
+ }
4521
+ // The gate on/off flag is a normal enigma setting, so it goes through the settings route
4522
+ // the Settings page uses rather than growing a second writer for the same key.
4523
+ async function gateOnPost(on) {
4524
+ gateBool($("gateOnBtn"), on);
4525
+ setNote("gateCfgNote", on ? "Turning the gate on..." : "Turning the gate off...");
4526
+ try {
4527
+ const out = await (await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "gate", value: on }) })).json();
4528
+ setNote("gateCfgNote", out.ok ? (out.restartNote || "Saved. Restart your agent to apply.") : ("Could not change the gate: " + (out.error || "error")), out.ok ? "warn" : "err");
4529
+ } catch { setNote("gateCfgNote", "Could not reach the server.", "err"); }
4530
+ await loadGate();
4531
+ }
4532
+ $("gateRefresh").addEventListener("click", () => loadGate());
4235
4533
  $("gateCfg").addEventListener("input", () => syncGateSave("global"));
4236
4534
  $("gateRepoCfg").addEventListener("input", () => syncGateSave("repo"));
4237
4535
  $("gateCfgSave").addEventListener("click", () => saveGateCfg("global"));
4238
4536
  $("gateRepoCfgSave").addEventListener("click", () => saveGateCfg("repo"));
4537
+ $("gateOnBtn").addEventListener("click", () => gateOnPost($("gateOnBtn").dataset.on !== "1"));
4538
+ $("gateDaemonBtn").addEventListener("click", () => gateDaemonPost($("gateDaemonBtn").dataset.on !== "1"));
4539
+ $("gateAgent").addEventListener("change", (e) => saveGateSetting("agent", e.target.value, "the agent"));
4540
+ $("gateLogLevel").addEventListener("change", (e) => saveGateSetting("log_level", e.target.value, "the log level"));
4541
+ $("gateIntent").addEventListener("click", () => saveGateSetting("intent.enabled", $("gateIntent").dataset.on !== "1", "intent extraction"));
4542
+ $("gateModelSave").addEventListener("click", () => saveGateSetting("model", $("gateModel").value.trim(), "the model"));
4543
+ $("gateCiSave").addEventListener("click", () => saveGateSetting("ci_timeout", $("gateCiTimeout").value.trim(), "the CI timeout"));
4544
+ // Number inputs commit on change (blur or Enter), so typing "12" is not read as "1" then "12".
4545
+ $("gateAutoFix").addEventListener("change", (e) => {
4546
+ const step = e.target.dataset && e.target.dataset.gateAf;
4547
+ if (step) saveGateSetting("auto_fix." + step, Number(e.target.value), "the " + step + " limit");
4548
+ });
4239
4549
 
4240
4550
  // --- section rail (a full-screen drawer below 900px) ---
4241
4551
  const sidebarEl = $("sidebar"), navScrimEl = $("navScrim"), navOpenBtn = $("navOpen");
@@ -4262,6 +4572,149 @@ export {
4262
4572
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && sidebarEl.classList.contains("open")) setNav(false); });
4263
4573
  window.matchMedia("(min-width: 901px)").addEventListener("change", (e) => { if (e.matches) setNav(false); });
4264
4574
 
4575
+ // --- command palette (Cmd/Ctrl+K) ---------------------------------------------
4576
+ // Twelve views plus every registered project is more than the rail can answer by
4577
+ // pointing, so typing is the second way in (frontend-policy, "Ctrl+K Opens A Command
4578
+ // Palette"). enigma: the dashboard is a single dependency-free HTML file served over
4579
+ // loopback, so the ~20-line matcher below stands in for fuse.js - upgrade to fuse.js
4580
+ // if this page ever gains a build step.
4581
+ const palBack = $("palBack"), palInput = $("palInput"), palList = $("palList");
4582
+ const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
4583
+ let palItems = [], palSel = 0, palReturnFocus = null;
4584
+ $("palHint").textContent = IS_MAC ? "Cmd K" : "Ctrl K";
4585
+
4586
+ /**
4587
+ * Fuzzy rank of `query` inside `text`: -1 when the query is not a subsequence, otherwise
4588
+ * a score that rewards consecutive runs and word starts. Beats a substring filter because
4589
+ * "qgate" finds "Quality gate" and "plyg" finds "Playground", which .includes() cannot.
4590
+ */
4591
+ function palScore(query, text) {
4592
+ const q = query.toLowerCase(), t = text.toLowerCase();
4593
+ if (!q) return 0;
4594
+ let at = 0, from = 0, score = 0, run = 0;
4595
+ for (let i = 0; i < q.length; i++) {
4596
+ at = t.indexOf(q[i], from);
4597
+ if (at === -1) return -1;
4598
+ run = (i > 0 && at === from) ? run + 1 : 0;
4599
+ const wordStart = at === 0 || /[^a-z0-9]/.test(t[at - 1]);
4600
+ score += 10 + run * 6 + (wordStart ? 8 : 0) - Math.min(at - from, 6);
4601
+ from = at + 1;
4602
+ }
4603
+ return score;
4604
+ }
4605
+
4606
+ /** Built from the live DOM on every open, so it can never drift from the rail or the scopes. */
4607
+ function palIndex() {
4608
+ const out = [];
4609
+ document.querySelectorAll(".tabs .tab").forEach((t) => {
4610
+ if (t.classList.contains("disabled")) return; // out of reach in this scope
4611
+ const group = t.closest(".nav-group");
4612
+ const label = (t.querySelector("span") || t).textContent.trim();
4613
+ const icon = t.querySelector(".tab-ic");
4614
+ out.push({
4615
+ kind: "View", name: label, hint: t.getAttribute("href"),
4616
+ icon: icon ? icon.outerHTML : "",
4617
+ group: group && group.querySelector(".nav-group-t") ? group.querySelector(".nav-group-t").textContent.trim() : "",
4618
+ run: () => { location.hash = t.getAttribute("href"); },
4619
+ });
4620
+ });
4621
+ const sel = $("scopeSel");
4622
+ if (sel) Array.from(sel.options).forEach((o) => {
4623
+ if (o.value === "__create__" || o.value === currentScope) return;
4624
+ out.push({
4625
+ kind: "Scope", name: o.textContent.trim(), hint: o.value === "global" ? "" : "project", icon: "", group: "",
4626
+ run: () => { sel.value = o.value; syncDD(sel); sel.dispatchEvent(new Event("change")); },
4627
+ });
4628
+ });
4629
+ return out;
4630
+ }
4631
+
4632
+ function palRender(query) {
4633
+ const q = query.trim();
4634
+ const all = palIndex();
4635
+ palItems = (!q ? all : all
4636
+ .map((it) => ({ it, s: Math.max(palScore(q, it.name), palScore(q, it.kind + " " + it.name) - 4) }))
4637
+ .filter((r) => r.s >= 0)
4638
+ .sort((a, b) => b.s - a.s)
4639
+ .map((r) => r.it));
4640
+ palSel = 0;
4641
+ if (!palItems.length) {
4642
+ palList.innerHTML = '<div class="pal-empty">No view or project matches "' + esc(q) + '".</div>';
4643
+ palInput.removeAttribute("aria-activedescendant");
4644
+ return;
4645
+ }
4646
+ let html = "", kind = null;
4647
+ palItems.forEach((it, i) => {
4648
+ if (it.kind !== kind) { kind = it.kind; html += '<div class="pal-gt">' + esc(kind === "View" ? "Views" : "Switch scope") + "</div>"; }
4649
+ html += '<div class="pal-row' + (i === 0 ? " sel" : "") + '" role="option" id="pal-o' + i + '"'
4650
+ + ' aria-selected="' + (i === 0 ? "true" : "false") + '" data-i="' + i + '">'
4651
+ + it.icon
4652
+ + '<span class="pal-name">' + esc(it.name) + "</span>"
4653
+ + (it.group ? '<span class="pal-hint">' + esc(it.group) + "</span>" : (it.hint ? '<span class="pal-hint">' + esc(it.hint) + "</span>" : ""))
4654
+ + "</div>";
4655
+ });
4656
+ palList.innerHTML = html;
4657
+ palMove(0);
4658
+ }
4659
+
4660
+ function palMove(next) {
4661
+ if (!palItems.length) return;
4662
+ const rows = palList.querySelectorAll(".pal-row");
4663
+ palSel = (next + palItems.length) % palItems.length;
4664
+ rows.forEach((r, i) => {
4665
+ const on = i === palSel;
4666
+ r.classList.toggle("sel", on);
4667
+ r.setAttribute("aria-selected", on ? "true" : "false");
4668
+ if (on) { r.scrollIntoView({ block: "nearest" }); palInput.setAttribute("aria-activedescendant", r.id); }
4669
+ });
4670
+ }
4671
+
4672
+ function palOpen() {
4673
+ if (!palBack.hidden) return;
4674
+ palReturnFocus = document.activeElement;
4675
+ palBack.hidden = false;
4676
+ document.body.classList.add("nav-open"); // reuses the drawer's scroll lock
4677
+ requestAnimationFrame(() => palBack.classList.add("show"));
4678
+ palInput.value = "";
4679
+ palRender("");
4680
+ palInput.focus();
4681
+ }
4682
+ function palClose() {
4683
+ if (palBack.hidden) return;
4684
+ palBack.classList.remove("show");
4685
+ setTimeout(() => { palBack.hidden = true; }, 200);
4686
+ if (!sidebarEl.classList.contains("open")) document.body.classList.remove("nav-open");
4687
+ if (palReturnFocus) { try { palReturnFocus.focus(); } catch { /* gone from the DOM */ } palReturnFocus = null; }
4688
+ }
4689
+ function palRun() {
4690
+ const it = palItems[palSel];
4691
+ if (!it) return;
4692
+ palClose();
4693
+ setNav(false);
4694
+ it.run();
4695
+ }
4696
+
4697
+ $("palOpen").addEventListener("click", palOpen);
4698
+ palInput.addEventListener("input", () => palRender(palInput.value));
4699
+ palList.addEventListener("mousemove", (e) => { const r = e.target.closest(".pal-row"); if (r) palMove(Number(r.dataset.i)); });
4700
+ palList.addEventListener("click", (e) => { const r = e.target.closest(".pal-row"); if (r) { palMove(Number(r.dataset.i)); palRun(); } });
4701
+ palBack.addEventListener("mousedown", (e) => { if (e.target === palBack) palClose(); });
4702
+ // Focus stays inside: the dialog holds one input and a list driven from it.
4703
+ palBack.addEventListener("keydown", (e) => {
4704
+ if (e.key === "ArrowDown") { e.preventDefault(); palMove(palSel + 1); }
4705
+ else if (e.key === "ArrowUp") { e.preventDefault(); palMove(palSel - 1); }
4706
+ else if (e.key === "Enter") { e.preventDefault(); palRun(); }
4707
+ // Escape closes the topmost surface only: the drawer behind it stays open.
4708
+ else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); palClose(); }
4709
+ else if (e.key === "Tab") e.preventDefault();
4710
+ });
4711
+ document.addEventListener("keydown", (e) => {
4712
+ // Ctrl+K is the browser's own search-bar shortcut, so it has to be taken explicitly.
4713
+ if (e.key.toLowerCase() !== "k" || !(IS_MAC ? e.metaKey : e.ctrlKey) || e.altKey) return;
4714
+ e.preventDefault();
4715
+ if (palBack.hidden) palOpen(); else palClose();
4716
+ });
4717
+
4265
4718
  // --- hash routing between the subpages ---
4266
4719
  // Single source of truth for the subpage ids (savings is the default/fallback).
4267
4720
  const VIEWS = ["savings", "usage", "accounts", "skills", "settings", "gate", "resources", "recall", "codegraph", "playground", "packs", "ssh"];
@@ -4277,7 +4730,20 @@ export {
4277
4730
  if (project && PROJECT_VIEWS.indexOf(v) === -1) v = "settings";
4278
4731
  activeView = v;
4279
4732
  VIEWS.forEach((view) => { $("view-" + view).style.display = view === v ? "" : "none"; });
4280
- document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.view === v));
4733
+ document.querySelectorAll(".tab").forEach((t) => {
4734
+ const on = t.dataset.view === v;
4735
+ t.classList.toggle("active", on);
4736
+ // Colour alone does not carry the active state to a screen reader.
4737
+ if (on) t.setAttribute("aria-current", "page"); else t.removeAttribute("aria-current");
4738
+ });
4739
+ // A group whose every entry is out of scope would otherwise render as a lone caption.
4740
+ document.querySelectorAll(".nav-group").forEach((g) => {
4741
+ const tabs = Array.from(g.querySelectorAll(".tab"));
4742
+ g.hidden = tabs.length > 0 && tabs.every((t) => t.classList.contains("disabled"));
4743
+ });
4744
+ // Leaving the view must not keep polling for runs. Kept OUT of the chain below: an
4745
+ // `if` in the middle of it detaches every later `else if` from the dispatch.
4746
+ if (v !== "gate") gateStopPoll();
4281
4747
  // Settings + Skills always reload (they are scope-dependent); the rest keep their once-load.
4282
4748
  if (v === "settings") loadSettings();
4283
4749
  else if (v === "skills") loadSkills();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/dashboard",
3
- "version": "0.1.96",
3
+ "version": "0.1.97",
4
4
  "description": "Local browser dashboard UI for enigma: the static page and chart assets enigma serves on its loopback dashboard (savings, real tool usage, in-browser settings). Installed on demand by enigma-cli; not a runtime dependency.",
5
5
  "type": "module",
6
6
  "files": [