great-cto 2.96.0 → 2.98.0
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.
- package/board/.claude-plugin/plugin.json +5 -1
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +39 -0
- package/board/packages/board/lib/routes.mjs +107 -0
- package/board/packages/board/public/index.html +438 -18
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/dist/detect.js +1 -0
- package/package.json +1 -1
|
@@ -163,6 +163,34 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
163
163
|
border-right: 1px solid var(--border);
|
|
164
164
|
display: flex; flex-direction: column;
|
|
165
165
|
}
|
|
166
|
+
/* Below this width the fixed 240px sidebar left 135px of workspace on a 375px
|
|
167
|
+
screen — narrower than a single kanban column (264px) — so every visit from a
|
|
168
|
+
phone was broken. It becomes a drawer: off-canvas by default, over the content
|
|
169
|
+
when opened.
|
|
170
|
+
|
|
171
|
+
`visibility` matters as much as the transform. A panel merely moved off-screen
|
|
172
|
+
is still in the tab order, so a keyboard user would tab through an invisible
|
|
173
|
+
menu — the same defect as the one this file just fixed on the Inbox tab, in
|
|
174
|
+
the opposite direction. */
|
|
175
|
+
.sidebar-scrim {
|
|
176
|
+
display: none;
|
|
177
|
+
position: fixed; inset: 0; z-index: 39;
|
|
178
|
+
background: rgba(0, 0, 0, 0.4);
|
|
179
|
+
opacity: 0; pointer-events: none;
|
|
180
|
+
transition: opacity 200ms ease-out;
|
|
181
|
+
}
|
|
182
|
+
.menu-btn {
|
|
183
|
+
display: none;
|
|
184
|
+
align-items: center; justify-content: center;
|
|
185
|
+
width: 40px; height: 40px; flex-shrink: 0;
|
|
186
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
187
|
+
background: var(--bg-card); color: var(--text2);
|
|
188
|
+
}
|
|
189
|
+
.menu-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
190
|
+
|
|
191
|
+
@media (prefers-reduced-motion: reduce) {
|
|
192
|
+
.sidebar, .sidebar-scrim { transition: none; }
|
|
193
|
+
}
|
|
166
194
|
.sidebar-brand {
|
|
167
195
|
display: flex; align-items: center; gap: 8px;
|
|
168
196
|
padding: 14px 16px;
|
|
@@ -328,7 +356,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
328
356
|
margin: 0 16px;
|
|
329
357
|
transition: border-color .12s, background .12s;
|
|
330
358
|
}
|
|
331
|
-
.search-box:focus-within { border-color: var(--
|
|
359
|
+
.search-box:focus-within { border-color: var(--focus-ring); background: var(--bg-card); }
|
|
332
360
|
.search-box svg { color: var(--text3); flex-shrink: 0; }
|
|
333
361
|
.search-box input {
|
|
334
362
|
flex: 1; border: none; outline: none; background: transparent;
|
|
@@ -530,6 +558,17 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
530
558
|
background: var(--bg-card);
|
|
531
559
|
transition: background 0.12s, border-color 0.12s;
|
|
532
560
|
}
|
|
561
|
+
/* A thumb needs a bigger target than a cursor does.
|
|
562
|
+
24px is comfortable with a mouse and a coin-flip with a thumb, and Approve sits
|
|
563
|
+
4px from Reject — the one pair on this board where a miss advances work that
|
|
564
|
+
cannot be un-advanced. Scoped to `pointer: coarse` so the desktop density that
|
|
565
|
+
makes a long gate list scannable is left exactly as it is. 44px is the iOS
|
|
566
|
+
minimum; Android asks for 48dp, and the gap does the rest. */
|
|
567
|
+
@media (pointer: coarse) {
|
|
568
|
+
.gate-btn { height: 44px; font-size: 13px; }
|
|
569
|
+
.inbox-row .actions .gate-btn { height: 44px; font-size: 13px; }
|
|
570
|
+
.gate-actions, .side-gate-actions, .inbox-row .actions { gap: 12px; }
|
|
571
|
+
}
|
|
533
572
|
.gate-approve { color: var(--status-review); border-color: rgba(22, 163, 74, 0.35); }
|
|
534
573
|
.gate-approve:hover { background: rgba(22, 163, 74, 0.08); border-color: var(--status-review); }
|
|
535
574
|
/* Agent runner (launch control) */
|
|
@@ -718,7 +757,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
718
757
|
.toast code { background: rgba(255,255,255,0.1); padding: 1px 4px; border-radius: 3px; }
|
|
719
758
|
.pl-stage { cursor: pointer; transition: background 0.12s; }
|
|
720
759
|
.pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
|
|
721
|
-
.card-selected { outline: 2px solid var(--
|
|
760
|
+
.card-selected { outline: 2px solid var(--focus-ring); outline-offset: -1px; }
|
|
722
761
|
#hotkey-modal {
|
|
723
762
|
position: fixed; inset: 0; z-index: 10000;
|
|
724
763
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -1202,6 +1241,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1202
1241
|
}
|
|
1203
1242
|
.cost-cell .v {
|
|
1204
1243
|
font-family: var(--serif); font-weight: 500;
|
|
1244
|
+
font-variant-numeric: tabular-nums;
|
|
1205
1245
|
font-size: 26px; letter-spacing: -0.02em;
|
|
1206
1246
|
color: var(--text); line-height: 1.1;
|
|
1207
1247
|
}
|
|
@@ -1544,9 +1584,21 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1544
1584
|
.metric-card.purple::before { background: #7c3aed; }
|
|
1545
1585
|
.metric-num {
|
|
1546
1586
|
font-family: var(--serif); font-weight: 500;
|
|
1587
|
+
/* The board is live (SSE). In a proportional face a 1 is narrower than an 8,
|
|
1588
|
+
so a figure that ticks over re-flows its own width and the number appears to
|
|
1589
|
+
breathe. Tabular figures cost nothing here and stop it. */
|
|
1590
|
+
font-variant-numeric: tabular-nums;
|
|
1547
1591
|
font-size: 52px; line-height: 1;
|
|
1548
1592
|
letter-spacing: -0.02em;
|
|
1549
1593
|
}
|
|
1594
|
+
/* An absence is rendered quietly and never silently — the glyph stays out of the
|
|
1595
|
+
way, the reason is available on hover and to a screen reader. Sized down from
|
|
1596
|
+
whatever it sits in, so a missing hero figure does not shout at 52px. */
|
|
1597
|
+
.absent {
|
|
1598
|
+
font-family: var(--mono); font-size: 0.55em;
|
|
1599
|
+
color: var(--text3); letter-spacing: 0.04em;
|
|
1600
|
+
cursor: help; border-bottom: 1px dotted var(--border-strong);
|
|
1601
|
+
}
|
|
1550
1602
|
.metric-num small {
|
|
1551
1603
|
font-size: 22px; color: var(--text2);
|
|
1552
1604
|
margin-left: 4px; font-weight: 400;
|
|
@@ -2165,6 +2217,48 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2165
2217
|
@media (max-width:480px) {
|
|
2166
2218
|
.chip-filter-bar { flex-direction: column; align-items: stretch; }
|
|
2167
2219
|
}
|
|
2220
|
+
@media (max-width: 768px) {
|
|
2221
|
+
.sidebar {
|
|
2222
|
+
position: fixed; top: 0; left: 0; bottom: 0;
|
|
2223
|
+
z-index: 40;
|
|
2224
|
+
transform: translateX(-100%);
|
|
2225
|
+
visibility: hidden;
|
|
2226
|
+
/* Visibility is switched, never eased. Transitioning it over the same 200ms
|
|
2227
|
+
as the transform flips it at the halfway mark, so the drawer is invisible
|
|
2228
|
+
for the first half of its own slide-in — and, worse, still unfocusable at
|
|
2229
|
+
the instant openMenu() tries to move focus into it, which silently left
|
|
2230
|
+
focus on the button and the menu unreachable by keyboard. On the way out
|
|
2231
|
+
it waits for the slide to finish (`0s linear 200ms`), so the panel does
|
|
2232
|
+
not vanish mid-animation. */
|
|
2233
|
+
transition: transform 200ms ease-out, visibility 0s linear 200ms;
|
|
2234
|
+
box-shadow: var(--drawer-shadow);
|
|
2235
|
+
}
|
|
2236
|
+
.sidebar.open {
|
|
2237
|
+
transform: none; visibility: visible;
|
|
2238
|
+
transition: transform 200ms ease-out, visibility 0s;
|
|
2239
|
+
}
|
|
2240
|
+
.sidebar-scrim { display: block; }
|
|
2241
|
+
.sidebar-scrim.open { opacity: 1; pointer-events: auto; }
|
|
2242
|
+
.menu-btn { display: inline-flex; }
|
|
2243
|
+
.topbar { padding: 0 10px; gap: 8px; }
|
|
2244
|
+
.topbar .crumbs { min-width: 0; overflow: hidden; white-space: nowrap; }
|
|
2245
|
+
.topbar .crumbs > span { overflow: hidden; text-overflow: ellipsis; }
|
|
2246
|
+
.topbar-actions { flex-shrink: 0; }
|
|
2247
|
+
/* The search box is the one topbar item that can give up its width; ⌘K still
|
|
2248
|
+
reaches it, and on a phone there is no ⌘K but there is the Search tab. */
|
|
2249
|
+
.topbar .search-box { display: none; }
|
|
2250
|
+
.topbar .task-count { display: none; }
|
|
2251
|
+
.topbar-actions { gap: 6px; }
|
|
2252
|
+
/* Icon-only, with the name moved to aria-label/title rather than deleted. */
|
|
2253
|
+
.btn-label { display: none; }
|
|
2254
|
+
|
|
2255
|
+
/* Four columns of numbers do not fit in 375px; they were being clipped, which
|
|
2256
|
+
silently dropped the fourth stat rather than wrapping it. */
|
|
2257
|
+
.inbox-summary { grid-template-columns: repeat(2, 1fr); }
|
|
2258
|
+
.mp-secondary { grid-template-columns: repeat(2, 1fr); }
|
|
2259
|
+
.mp-hero { grid-template-columns: 1fr; }
|
|
2260
|
+
.mp-row { grid-template-columns: 1fr; }
|
|
2261
|
+
}
|
|
2168
2262
|
@keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(76,175,80,.6);} 100% { box-shadow: 0 0 0 6px rgba(76,175,80,0);} }
|
|
2169
2263
|
/* ── Scrollbar ────────────────────────────────────────────────────────────── */
|
|
2170
2264
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
@@ -2178,7 +2272,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2178
2272
|
<div class="board-bg" aria-hidden="true"></div>
|
|
2179
2273
|
|
|
2180
2274
|
<!-- Sidebar -->
|
|
2181
|
-
<aside class="sidebar">
|
|
2275
|
+
<aside class="sidebar" id="sidebar">
|
|
2182
2276
|
<div class="sidebar-brand">
|
|
2183
2277
|
<span class="brand-mark">
|
|
2184
2278
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -2208,7 +2302,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2208
2302
|
</div>
|
|
2209
2303
|
</div>
|
|
2210
2304
|
<nav class="nav">
|
|
2211
|
-
<div class="nav-item active" data-tab="inbox" onclick="switchTab('inbox', this)">
|
|
2305
|
+
<div class="nav-item active" data-tab="inbox" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('inbox', this)">
|
|
2212
2306
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-6l-2 3h-4l-2-3H2 M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
|
2213
2307
|
<span>Inbox</span>
|
|
2214
2308
|
<span class="count" id="nav-inbox-count">0</span>
|
|
@@ -2246,9 +2340,15 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2246
2340
|
</div>
|
|
2247
2341
|
</aside>
|
|
2248
2342
|
|
|
2343
|
+
<div class="sidebar-scrim" id="sidebar-scrim" onclick="closeMenu()" aria-hidden="true"></div>
|
|
2344
|
+
|
|
2249
2345
|
<!-- Workspace -->
|
|
2250
2346
|
<div class="workspace">
|
|
2251
2347
|
<div class="topbar">
|
|
2348
|
+
<button class="menu-btn" id="menu-btn" type="button" aria-label="Menu"
|
|
2349
|
+
aria-controls="sidebar" aria-expanded="false" onclick="toggleMenu()">
|
|
2350
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18 M3 12h18 M3 18h18"/></svg>
|
|
2351
|
+
</button>
|
|
2252
2352
|
<div class="crumbs">
|
|
2253
2353
|
<span class="proj-icon" id="crumb-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></span>
|
|
2254
2354
|
<span id="crumb-project">—</span>
|
|
@@ -2264,7 +2364,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2264
2364
|
<span class="task-count" id="topbar-task-count">0 ISSUES</span>
|
|
2265
2365
|
<button class="btn-bell" data-tab="share" onclick="switchTab('share', this)" title="Share a public project report" aria-label="Share" style="gap:6px;padding:6px 10px;">
|
|
2266
2366
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
|
2267
|
-
<span style="font-size:12px;font-weight:500">Share</span>
|
|
2367
|
+
<span class="btn-label" style="font-size:12px;font-weight:500">Share</span>
|
|
2268
2368
|
<span id="share-nav-badge" class="share-badge off"><span class="share-badge-dot"></span>off</span>
|
|
2269
2369
|
</button>
|
|
2270
2370
|
<button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme">🌙</button>
|
|
@@ -2272,9 +2372,9 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2272
2372
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
|
2273
2373
|
<span class="btn-bell-badge" id="topbar-bell-badge"></span>
|
|
2274
2374
|
</button>
|
|
2275
|
-
<button class="btn-new" onclick="openNewIssueModal()">
|
|
2375
|
+
<button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
|
|
2276
2376
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14 M5 12h14"/></svg>
|
|
2277
|
-
New issue
|
|
2377
|
+
<span class="btn-label">New issue</span>
|
|
2278
2378
|
</button>
|
|
2279
2379
|
</div>
|
|
2280
2380
|
</div>
|
|
@@ -2284,6 +2384,9 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2284
2384
|
<div class="inbox-page">
|
|
2285
2385
|
<div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
|
|
2286
2386
|
<div class="inbox-summary" id="inbox-summary"></div>
|
|
2387
|
+
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
2388
|
+
still the bytes in the tree? Hidden while they match. -->
|
|
2389
|
+
<div id="inbox-receipt" style="display:none;margin:6px 0 2px;font-size:12px"></div>
|
|
2287
2390
|
|
|
2288
2391
|
<!-- Resume — pick up where you left off -->
|
|
2289
2392
|
<div class="resume-card" id="resume-card" style="display:none">
|
|
@@ -2332,6 +2435,17 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2332
2435
|
</div>
|
|
2333
2436
|
<div id="inbox-gates"></div>
|
|
2334
2437
|
</div>
|
|
2438
|
+
<!-- Gates that did NOT wait for you. Placed directly under the decisions
|
|
2439
|
+
you still owe, because "what proceeded without me" is the question a
|
|
2440
|
+
tiered gate creates and nothing on this board answered. -->
|
|
2441
|
+
<div class="inbox-section" id="inbox-standdown-section" style="display:none">
|
|
2442
|
+
<div class="inbox-section-head">
|
|
2443
|
+
<span class="dot dot-amber"></span>
|
|
2444
|
+
<h3>Proceeded without you</h3>
|
|
2445
|
+
<span class="inbox-count" id="inbox-standdown-count">0</span>
|
|
2446
|
+
</div>
|
|
2447
|
+
<div id="inbox-standdown"></div>
|
|
2448
|
+
</div>
|
|
2335
2449
|
<div class="inbox-section" id="inbox-p0-section">
|
|
2336
2450
|
<div class="inbox-section-head">
|
|
2337
2451
|
<span class="dot dot-red"></span>
|
|
@@ -2754,7 +2868,12 @@ function greetByHour() {
|
|
|
2754
2868
|
function renderInbox(d) {
|
|
2755
2869
|
inboxData = d || {};
|
|
2756
2870
|
const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
|
|
2757
|
-
|
|
2871
|
+
// `needs_you` counts distinct objects. Summing the three categories double-counted
|
|
2872
|
+
// anything that matched two of them — a P0 that is also blocked read as two things
|
|
2873
|
+
// needing attention when it was one. Older payloads have no `needs_you`, so the sum
|
|
2874
|
+
// stays as the fallback rather than rendering nothing.
|
|
2875
|
+
document.getElementById('nav-inbox-count').textContent =
|
|
2876
|
+
Number.isFinite(s.needs_you) ? s.needs_you : s.gates + s.p0 + s.blocked;
|
|
2758
2877
|
// Same rule as the all-clear card: "nothing urgent" is a claim about data we
|
|
2759
2878
|
// may not have. When a read failed, say that instead of reassuring the user.
|
|
2760
2879
|
const greetTail = anyDegraded()
|
|
@@ -2771,6 +2890,8 @@ function renderInbox(d) {
|
|
|
2771
2890
|
renderInboxList('inbox-p0', d?.p0_open || [], 'inbox-p0-count', {});
|
|
2772
2891
|
renderInboxList('inbox-blocked', d?.blocked || [], 'inbox-blocked-count', {});
|
|
2773
2892
|
renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
|
|
2893
|
+
refreshStandDowns();
|
|
2894
|
+
refreshReceipt();
|
|
2774
2895
|
// Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
|
|
2775
2896
|
const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
|
|
2776
2897
|
+ (d?.blocked || []).length + (d?.stale_in_progress || []).length;
|
|
@@ -2785,6 +2906,150 @@ function renderInbox(d) {
|
|
|
2785
2906
|
'Some project data could not be read — counts below are incomplete.');
|
|
2786
2907
|
}
|
|
2787
2908
|
|
|
2909
|
+
/**
|
|
2910
|
+
* Does the last approval still describe what is in the tree?
|
|
2911
|
+
*
|
|
2912
|
+
* The reviewing agents already record a hash-bound receipt of the bytes they
|
|
2913
|
+
* read. Nothing on this board looked at it, so the top rung of the evidence
|
|
2914
|
+
* ladder was being written and never read.
|
|
2915
|
+
*
|
|
2916
|
+
* Five readings, and only three of them speak:
|
|
2917
|
+
* differs reviewed files changed after the approval — the actionable one
|
|
2918
|
+
* unreadable we could not tell. Shown, because "cannot check" must never
|
|
2919
|
+
* render as "checked and fine"
|
|
2920
|
+
* extended new files appeared; nothing reviewed was altered — worth a
|
|
2921
|
+
* quiet line, since the addition was never reviewed either
|
|
2922
|
+
* matches silent. A clean state does not need a banner.
|
|
2923
|
+
* no-receipt silent. Nothing has been approved yet; saying so would nag
|
|
2924
|
+
* about a stage that has not happened.
|
|
2925
|
+
*/
|
|
2926
|
+
async function refreshReceipt() {
|
|
2927
|
+
const el = document.getElementById('inbox-receipt');
|
|
2928
|
+
if (!el) return;
|
|
2929
|
+
const d = await api(`/api/receipt${pqs()}`);
|
|
2930
|
+
if (!d || d.state === 'matches' || d.state === 'no-receipt') { el.style.display = 'none'; return; }
|
|
2931
|
+
|
|
2932
|
+
const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
|
|
2933
|
+
const body = d.state === 'differs'
|
|
2934
|
+
? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
|
|
2935
|
+
— the approval no longer describes what is in the tree.
|
|
2936
|
+
<span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
|
|
2937
|
+
: d.state === 'unreadable'
|
|
2938
|
+
? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
|
|
2939
|
+
: `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
|
|
2940
|
+
|
|
2941
|
+
const colour = d.state === 'matches' ? 'var(--text3)' : 'var(--status-gate)';
|
|
2942
|
+
el.style.display = '';
|
|
2943
|
+
el.innerHTML = `<span style="color:${colour}">${body}</span>`;
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/**
|
|
2947
|
+
* Is this document still true? — which is not the question `modified` answers.
|
|
2948
|
+
*
|
|
2949
|
+
* Three states, and the two that are not `fresh` look different from each other:
|
|
2950
|
+
*
|
|
2951
|
+
* stale the author's own `stale_after` has passed, or its date is older
|
|
2952
|
+
* than the threshold — say so, loudly, it is the actionable one
|
|
2953
|
+
* unknown nothing to judge it by, OR the file could not be read. Both are
|
|
2954
|
+
* shown, because a document nobody could judge must never render the
|
|
2955
|
+
* same as one that passed. That equivalence is why stale_after exists.
|
|
2956
|
+
* fresh nothing — a clean row should stay quiet
|
|
2957
|
+
*
|
|
2958
|
+
* Absent freshness data (an older board payload) also renders nothing, since
|
|
2959
|
+
* inventing a verdict from a missing field is the defect itself.
|
|
2960
|
+
*/
|
|
2961
|
+
function freshnessBadge(doc) {
|
|
2962
|
+
const v = doc?.freshness;
|
|
2963
|
+
if (!v || v === 'fresh') return '';
|
|
2964
|
+
const unreadable = doc.freshnessBasis === 'unreadable';
|
|
2965
|
+
const colour = v === 'stale' ? 'var(--status-gate)' : 'var(--text3)';
|
|
2966
|
+
const label = v === 'stale' ? 'stale' : (unreadable ? 'could not read' : 'unknown');
|
|
2967
|
+
return `<span title="${esc(doc.freshnessWhy || '')}" style="font-family:var(--mono);font-size:10px;margin-left:6px;padding:0 4px;border:1px solid var(--border);border-radius:3px;color:${colour}">${esc(label)}</span>`;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
// Will this gate wait for you, or did the pipeline already go past it?
|
|
2971
|
+
//
|
|
2972
|
+
// `null` until fetched, and it stays null on any failure — every render path
|
|
2973
|
+
// treats "no tier information" as "this gate waits", which is the reading that
|
|
2974
|
+
// keeps a human in the loop when we do not know.
|
|
2975
|
+
let gateTiers = null;
|
|
2976
|
+
async function refreshGateTiers() {
|
|
2977
|
+
const d = await api(`/api/gate-tiers${pqs()}`);
|
|
2978
|
+
gateTiers = d && d.agents ? d : null;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
/**
|
|
2982
|
+
* The tier line for a gate card, or '' when it would say nothing true.
|
|
2983
|
+
*
|
|
2984
|
+
* Three readings, not two:
|
|
2985
|
+
* - tiering off for this project, or the agent unmeasured → nothing shown, the
|
|
2986
|
+
* gate waits, which is what the buttons already imply
|
|
2987
|
+
* - `gated` → nothing shown for the same reason
|
|
2988
|
+
* - `notify` / `notify-thin` → said out loud, because the pipeline did NOT
|
|
2989
|
+
* wait for this decision and the Approve button alone implies it did
|
|
2990
|
+
*/
|
|
2991
|
+
function gateTierNote(t) {
|
|
2992
|
+
if (!gateTiers?.enabled || !gateTiers?.measured) return '';
|
|
2993
|
+
const info = gateTiers.agents?.[t.agent];
|
|
2994
|
+
if (!info || info.tier === 'gated') return '';
|
|
2995
|
+
const thin = info.tier === 'notify-thin';
|
|
2996
|
+
const colour = thin ? 'var(--status-gate)' : 'var(--text3)';
|
|
2997
|
+
// `notify-thin` is sound statistics over unmeasured coverage — one eval passed
|
|
2998
|
+
// conclusively, which says nothing about the rest of what this agent is for.
|
|
2999
|
+
// It is the reading most worth a second look, so it reads differently.
|
|
3000
|
+
const caveat = thin ? ' — one eval, coverage unmeasured' : '';
|
|
3001
|
+
return `<div style="margin-top:3px;font-size:11px;color:${colour}">
|
|
3002
|
+
<span style="font-family:var(--mono);border:1px solid var(--border);border-radius:3px;padding:0 4px">${esc(info.tier)}</span>
|
|
3003
|
+
the pipeline did not wait for this${esc(caveat)} · <span title="${esc(info.why || '')}">${esc((info.why || '').slice(0, 90))}</span>
|
|
3004
|
+
</div>`;
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// Gates that announced instead of waiting.
|
|
3008
|
+
//
|
|
3009
|
+
// Three states, not two. `none` hides the section — nothing has proceeded
|
|
3010
|
+
// unattended, which is a real and common answer. `unreadable` SHOWS it and says
|
|
3011
|
+
// so: a ledger that exists and could not be read must never render the same as
|
|
3012
|
+
// an empty one, because the whole reason this record exists is that a
|
|
3013
|
+
// stand-down nobody can see is indistinguishable from a gate nobody configured.
|
|
3014
|
+
async function refreshStandDowns() {
|
|
3015
|
+
const section = document.getElementById('inbox-standdown-section');
|
|
3016
|
+
const root = document.getElementById('inbox-standdown');
|
|
3017
|
+
const cnt = document.getElementById('inbox-standdown-count');
|
|
3018
|
+
if (!section || !root) return;
|
|
3019
|
+
|
|
3020
|
+
const d = await api(`/api/stand-downs${pqs()}`);
|
|
3021
|
+
if (!d) { section.style.display = 'none'; return; }
|
|
3022
|
+
|
|
3023
|
+
if (d.state === 'unreadable') {
|
|
3024
|
+
section.style.display = '';
|
|
3025
|
+
if (cnt) cnt.textContent = '?';
|
|
3026
|
+
root.innerHTML = `<div class="inbox-row"><span class="ttl">The stand-down ledger could not be read
|
|
3027
|
+
<span class="meta">${esc(d.why || '')} — gates may have proceeded unattended without appearing here</span></span></div>`;
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
if (d.state !== 'some' || !d.rows.length) { section.style.display = 'none'; root.innerHTML = ''; return; }
|
|
3031
|
+
|
|
3032
|
+
section.style.display = '';
|
|
3033
|
+
if (cnt) cnt.textContent = d.rows.length;
|
|
3034
|
+
// Newest first — the ledger appends, but a reader wants the most recent thing
|
|
3035
|
+
// that happened without them.
|
|
3036
|
+
root.innerHTML = d.rows.slice().reverse().map(r => {
|
|
3037
|
+
// `notify-thin` means the gate stood down on ONE eval: sound statistics,
|
|
3038
|
+
// unmeasured coverage. That is the row most worth a second look, so it is
|
|
3039
|
+
// the row that looks different.
|
|
3040
|
+
const thin = r.tier === 'notify-thin';
|
|
3041
|
+
const tierTag = `<span style="font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:${thin ? 'var(--status-gate)' : 'var(--text3)'}">${esc(r.tier || 'unknown')}</span>`;
|
|
3042
|
+
return `
|
|
3043
|
+
<div class="inbox-row">
|
|
3044
|
+
<span class="id">gate:${esc(r.gate || '?')}</span>
|
|
3045
|
+
<span class="ttl">${esc(r.agent || 'unknown agent')} proceeded without asking
|
|
3046
|
+
<span class="ttl-desc">${esc(r.evidence || '')}</span>
|
|
3047
|
+
<span class="meta">${tierTag} · <span title="${esc(r.ts || '')}">${r.ts ? relTime(r.ts) : 'no timestamp'}</span></span>
|
|
3048
|
+
</span>
|
|
3049
|
+
</div>`;
|
|
3050
|
+
}).join('');
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2788
3053
|
function renderInboxList(rootId, items, countId, opts = {}) {
|
|
2789
3054
|
const root = document.getElementById(rootId);
|
|
2790
3055
|
const cnt = document.getElementById(countId);
|
|
@@ -2794,9 +3059,17 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2794
3059
|
if (section) section.style.display = items.length ? '' : 'none';
|
|
2795
3060
|
if (!items.length) { root.innerHTML = ''; return; }
|
|
2796
3061
|
root.innerHTML = items.map(t => {
|
|
3062
|
+
// A task now appears in one section only, so the states it ALSO matched are
|
|
3063
|
+
// shown here rather than lost. Deduplicating the rows must not deduplicate
|
|
3064
|
+
// the facts: a gate that is also blocked is still blocked, and hiding that
|
|
3065
|
+
// to fix a double count would trade one wrong reading for another.
|
|
3066
|
+
const alsoTag = (t.also || []).length
|
|
3067
|
+
? `<span title="also matched: ${esc((t.also || []).join(', '))}" style="color:var(--text3)">also ${esc((t.also || []).join(' + '))}</span>`
|
|
3068
|
+
: '';
|
|
2797
3069
|
const meta = [
|
|
2798
3070
|
t.agent ? `agent: ${esc(t.agent)}` : '',
|
|
2799
3071
|
t.priority != null ? PRIORITY_LABEL[t.priority] : '',
|
|
3072
|
+
alsoTag,
|
|
2800
3073
|
// Gates (the showApprove list) get the exact date+time the decision has been
|
|
2801
3074
|
// waiting since; other rows keep the compact relative stamp.
|
|
2802
3075
|
t.updated_at
|
|
@@ -2812,16 +3085,21 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
2812
3085
|
</div>`
|
|
2813
3086
|
: `<div class="actions"><span style="font-family:var(--mono);font-size:11px;color:var(--text3)">${t.status || ''}</span></div>`;
|
|
2814
3087
|
const desc = t.description ? `<span class="ttl-desc">${esc(t.description.slice(0, 140))}${t.description.length > 140 ? '…' : ''}</span>` : '';
|
|
3088
|
+
// Only gate rows can stand down, so only they carry the note.
|
|
3089
|
+
const tierNote = opts.showApprove ? gateTierNote(t) : '';
|
|
2815
3090
|
return `
|
|
2816
3091
|
<div class="inbox-row" onclick='openSide(${JSON.stringify(t).replace(/'/g, "'")})'>
|
|
2817
3092
|
<span class="id">${esc(t.id || '')}</span>
|
|
2818
|
-
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span
|
|
3093
|
+
<span class="ttl">${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
2819
3094
|
${actions}
|
|
2820
3095
|
</div>`;
|
|
2821
3096
|
}).join('');
|
|
2822
3097
|
}
|
|
2823
3098
|
|
|
2824
3099
|
async function refreshInbox() {
|
|
3100
|
+
// Tiers first: the gate rows are rendered from this same call, and a row drawn
|
|
3101
|
+
// before the tiers arrive would silently claim every gate is waiting.
|
|
3102
|
+
await refreshGateTiers();
|
|
2825
3103
|
const d = await api(`/api/inbox${pqs()}`);
|
|
2826
3104
|
if (d) renderInbox(d);
|
|
2827
3105
|
refreshPipeline();
|
|
@@ -3458,7 +3736,7 @@ async function loadDocs() {
|
|
|
3458
3736
|
|
|
3459
3737
|
const pipeline = d.pipeline
|
|
3460
3738
|
? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
|
|
3461
|
-
${
|
|
3739
|
+
${renderDeclaredPipeline(d.pipeline)}
|
|
3462
3740
|
<details style="margin-top:8px"><summary class="muted">Mermaid source</summary>
|
|
3463
3741
|
<pre style="white-space:pre-wrap;font-size:11px">${esc(d.pipeline)}</pre></details></div>`
|
|
3464
3742
|
: '';
|
|
@@ -3481,6 +3759,7 @@ async function loadDocs() {
|
|
|
3481
3759
|
<div style="padding:4px 0;border-bottom:1px solid var(--border,#eee)">
|
|
3482
3760
|
<a href="#" onclick="openDoc('${esc(doc.path)}');return false">${esc(doc.title)}</a>
|
|
3483
3761
|
<span class="muted" style="font-size:11px"> · ${esc(doc.path)} · ${ago(doc.modified)}</span>
|
|
3762
|
+
${freshnessBadge(doc)}
|
|
3484
3763
|
</div>`).join('')}</div>
|
|
3485
3764
|
</div>`).join('');
|
|
3486
3765
|
|
|
@@ -3541,7 +3820,18 @@ function projectRootHint() {
|
|
|
3541
3820
|
}
|
|
3542
3821
|
|
|
3543
3822
|
/** The pipeline as boxes and gates — no diagram library, so it always renders. */
|
|
3544
|
-
|
|
3823
|
+
// Named `renderDeclaredPipeline`, not `renderPipeline`, because there is already
|
|
3824
|
+
// a `renderPipeline(stages)` above that draws the LIVE rail. Both were declared
|
|
3825
|
+
// at the top level of this one <script>, so the later declaration won and every
|
|
3826
|
+
// call carrying an array of stages — refreshPipeline() and the SSE handler —
|
|
3827
|
+
// reached this mermaid parser instead. It returned a string nobody used and
|
|
3828
|
+
// never touched `#pipeline-track`, so the rail stayed the empty div in the
|
|
3829
|
+
// markup and the status stayed the hardcoded `idle` beside it.
|
|
3830
|
+
//
|
|
3831
|
+
// The first section of the first screen therefore read "idle" whatever the
|
|
3832
|
+
// pipeline was doing: not an empty state, but a confident wrong answer, which is
|
|
3833
|
+
// the failure this project spends its time removing.
|
|
3834
|
+
function renderDeclaredPipeline(mermaid) {
|
|
3545
3835
|
const edges = [];
|
|
3546
3836
|
for (const line of String(mermaid).split('\n')) {
|
|
3547
3837
|
const m = line.match(/^\s*(\w+)\s*-->(?:\|([^|]*)\|)?\s*(\w+)/);
|
|
@@ -4446,6 +4736,28 @@ function cardHTML(t) {
|
|
|
4446
4736
|
</div>`;
|
|
4447
4737
|
}
|
|
4448
4738
|
|
|
4739
|
+
// What clicking Approve actually sets in motion, in the words of what it does.
|
|
4740
|
+
//
|
|
4741
|
+
// A confirm that only asks "are you sure?" teaches people to dismiss confirms,
|
|
4742
|
+
// so this one states consequences instead: the approval is appended to the
|
|
4743
|
+
// verdict log, the pipeline is woken so the next session dispatches the
|
|
4744
|
+
// following stage, and — when the share report is on — a public URL is
|
|
4745
|
+
// republished. That last line is the one worth reading: it leaves the machine,
|
|
4746
|
+
// and revoking the link later does not un-see what was already served.
|
|
4747
|
+
function approveConsequence(id) {
|
|
4748
|
+
const lines = [
|
|
4749
|
+
`Approve ${id}?`,
|
|
4750
|
+
'',
|
|
4751
|
+
'Records your approval in the verdict log and wakes the pipeline —',
|
|
4752
|
+
'the next session dispatches the following stage.',
|
|
4753
|
+
];
|
|
4754
|
+
if (shareState && shareState.enabled) {
|
|
4755
|
+
lines.push('', `Also republishes the public report at ${shareState.url || 'the share URL'} —`,
|
|
4756
|
+
'anyone holding that link sees the update.');
|
|
4757
|
+
}
|
|
4758
|
+
return lines.join('\n');
|
|
4759
|
+
}
|
|
4760
|
+
|
|
4449
4761
|
async function gateAction(id, action, runAgent) {
|
|
4450
4762
|
let body;
|
|
4451
4763
|
if (action === 'reject') {
|
|
@@ -4453,6 +4765,16 @@ async function gateAction(id, action, runAgent) {
|
|
|
4453
4765
|
if (reason === null) return;
|
|
4454
4766
|
body = { action, reason, project: currentProject };
|
|
4455
4767
|
} else {
|
|
4768
|
+
// The gate is the human signature the pipeline waits on. Rejecting has
|
|
4769
|
+
// always asked for a reason and approving asked for nothing, so the cheaper
|
|
4770
|
+
// click was the one that advanced work nobody can un-advance — friction
|
|
4771
|
+
// pointing exactly the wrong way.
|
|
4772
|
+
//
|
|
4773
|
+
// Medium cost-of-undo (ADR-009): one confirm that NAMES the consequence, not
|
|
4774
|
+
// a typed-name ritual on a button pressed several times a day. The runAgent
|
|
4775
|
+
// path already shows an editable prompt that says what it will approve and
|
|
4776
|
+
// run, and is cancellable — a second dialog there would be pure friction.
|
|
4777
|
+
if (!runAgent && !confirm(approveConsequence(id))) return;
|
|
4456
4778
|
body = { action, project: currentProject };
|
|
4457
4779
|
if (runAgent) {
|
|
4458
4780
|
const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
|
|
@@ -4543,6 +4865,32 @@ async function updateTaskPriority(id, priority) {
|
|
|
4543
4865
|
}
|
|
4544
4866
|
}
|
|
4545
4867
|
|
|
4868
|
+
/* ── One glyph cannot mean five things ─────────────────────────────────────
|
|
4869
|
+
*
|
|
4870
|
+
* A single em dash was doing duty for at least five different facts on this
|
|
4871
|
+
* board: not loaded yet, measured and there is none, cannot be computed from
|
|
4872
|
+
* what we have, no verdict, no date. On a screen where the figures are money and
|
|
4873
|
+
* throughput, "we looked and it is zero" and "we could not look" are not the
|
|
4874
|
+
* same answer, and the reader was being asked to guess which one they had.
|
|
4875
|
+
*
|
|
4876
|
+
* Three kinds, because three is what actually occurs here. Vocabulary follows
|
|
4877
|
+
* the distinction statistical agencies draw between a value that is missing and
|
|
4878
|
+
* one that is not applicable; the wording is ours, the discipline is theirs.
|
|
4879
|
+
*
|
|
4880
|
+
* The caller always passes the specific reason. A generic tooltip that repeats
|
|
4881
|
+
* the glyph's category would add a hover and no information.
|
|
4882
|
+
*/
|
|
4883
|
+
const ABSENCE = {
|
|
4884
|
+
none: '—', // measured; the answer is that there is none
|
|
4885
|
+
uncomputable: 'n/a', // the inputs for this figure do not exist
|
|
4886
|
+
unloaded: '·', // no answer yet — the read has not landed
|
|
4887
|
+
};
|
|
4888
|
+
|
|
4889
|
+
function absent(kind, why) {
|
|
4890
|
+
const glyph = ABSENCE[kind] || ABSENCE.none;
|
|
4891
|
+
return `<span class="absent" title="${esc(why)}" aria-label="${esc(why)}">${glyph}</span>`;
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4546
4894
|
/* ── Dashboard ──────────────────────────────────────────────────────────── */
|
|
4547
4895
|
function renderDashboard(m) {
|
|
4548
4896
|
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
@@ -4553,6 +4901,18 @@ function renderDashboard(m) {
|
|
|
4553
4901
|
// tasks.md fallback with no dates) show a meaningless "—" despite real work.
|
|
4554
4902
|
// (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
|
|
4555
4903
|
const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
|
|
4904
|
+
// Three states, not two. `?? 0` folds "the payload has no tasks section" into
|
|
4905
|
+
// the same 0 as "zero tasks done", and the tile then rendered that 0 as an
|
|
4906
|
+
// absence — so a day-one project read "—" beside "+0 this week", the board
|
|
4907
|
+
// reporting a failure to measure something it had measured fine. The backend
|
|
4908
|
+
// always sends `tasks` (lib/metrics.mjs), so this is "—" only for a payload
|
|
4909
|
+
// that really is missing it.
|
|
4910
|
+
const doneKnown = m.tasks != null;
|
|
4911
|
+
const costKnown = m.cost != null;
|
|
4912
|
+
// NOT the same shape: avg_completion_min is a median over completions that
|
|
4913
|
+
// carry timestamps, and the backend emits 0 when there are none — so 0 here
|
|
4914
|
+
// means "could not be computed", not "took no time". "—" is the correct
|
|
4915
|
+
// reading; do not "fix" this into a literal 0 m.
|
|
4556
4916
|
const avgMin = m.avg_completion_min ?? 0;
|
|
4557
4917
|
const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
|
|
4558
4918
|
|
|
@@ -4575,9 +4935,11 @@ function renderDashboard(m) {
|
|
|
4575
4935
|
: (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
|
|
4576
4936
|
|
|
4577
4937
|
const hero = [
|
|
4578
|
-
{ v: done
|
|
4938
|
+
{ v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'), sub: 'shipped', label: 'Tasks done', trend: `+${m.velocity?.this_week ?? 0} this week` },
|
|
4579
4939
|
{
|
|
4580
|
-
|
|
4940
|
+
// $0.00 with nothing spent yet is a measurement; "—" would claim we never
|
|
4941
|
+
// looked. The trend line underneath already says which of the two it is.
|
|
4942
|
+
v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
|
|
4581
4943
|
sub: '',
|
|
4582
4944
|
label: 'AI spend',
|
|
4583
4945
|
trend: llmTrend,
|
|
@@ -4594,7 +4956,7 @@ function renderDashboard(m) {
|
|
|
4594
4956
|
: {
|
|
4595
4957
|
// No MEASURED savings yet (no verdict cost data). Don't fabricate a
|
|
4596
4958
|
// multiplier from the time-based estimate — show it's not measured.
|
|
4597
|
-
v: '
|
|
4959
|
+
v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
|
|
4598
4960
|
sub: '',
|
|
4599
4961
|
label: 'Cost savings vs FTE',
|
|
4600
4962
|
trend: aiSpend > 0
|
|
@@ -4612,12 +4974,12 @@ function renderDashboard(m) {
|
|
|
4612
4974
|
|
|
4613
4975
|
const acc = m.acceptance || {};
|
|
4614
4976
|
const secondary = [
|
|
4615
|
-
{ v: avgMin ? `${avgMin}` : '
|
|
4616
|
-
{ v: m.qa?.pass_rate != null ? m.qa.pass_rate : '
|
|
4977
|
+
{ v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'm' : '', label: cycleStat },
|
|
4978
|
+
{ v: m.qa?.pass_rate != null ? m.qa.pass_rate : absent('none', 'no QA run recorded in this window'), sub: m.qa?.pass_rate != null ? '%' : '', label: m.qa?.pass_rate != null ? 'QA pass rate' : 'QA — run /qa', cls: 'green' },
|
|
4617
4979
|
// Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
|
|
4618
4980
|
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
4619
4981
|
// honest gap, not a zero.
|
|
4620
|
-
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : '
|
|
4982
|
+
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
|
|
4621
4983
|
{ v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
|
|
4622
4984
|
{ v: m.security?.blocked ?? 0, sub: '', label: 'Open security blocks', cls: m.security?.blocked ? 'red' : 'green' },
|
|
4623
4985
|
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
@@ -4883,6 +5245,19 @@ function setNotifStatus(text, tone) {
|
|
|
4883
5245
|
|
|
4884
5246
|
async function toggleShare() {
|
|
4885
5247
|
const enabled = !document.getElementById('share-toggle').checked;
|
|
5248
|
+
// Only on the way ON. Switching sharing OFF is the safe direction and asking
|
|
5249
|
+
// there would train people to click through the dialog that matters. The card
|
|
5250
|
+
// already explains what publishing means, but a toggle can be flipped without
|
|
5251
|
+
// reading the card next to it, and revoking the link afterwards does not
|
|
5252
|
+
// un-see what was already served.
|
|
5253
|
+
if (enabled && !confirm(
|
|
5254
|
+
'Publish a public report for this project?\n\n' +
|
|
5255
|
+
'Anyone holding the link can read what shipped, the metrics and the activity,\n' +
|
|
5256
|
+
'with no sign-in. Approving a gate republishes it automatically.\n\n' +
|
|
5257
|
+
'Turning sharing off later revokes the link — it does not unpublish what has\n' +
|
|
5258
|
+
'already been read.')) {
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
4886
5261
|
document.getElementById('share-loading').style.display = enabled ? 'flex' : 'none';
|
|
4887
5262
|
document.getElementById('share-url-section').style.display = 'none';
|
|
4888
5263
|
// BUG FIX: scope publish to the currently-selected project, not server's cwd
|
|
@@ -5017,6 +5392,51 @@ function closeSide() {
|
|
|
5017
5392
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide(); });
|
|
5018
5393
|
|
|
5019
5394
|
/* ── Tabs ───────────────────────────────────────────────────────────────── */
|
|
5395
|
+
/* ── The sidebar as a drawer on narrow screens ──────────────────────────── */
|
|
5396
|
+
//
|
|
5397
|
+
// Three things a drawer owes a keyboard, all of them things a transform alone
|
|
5398
|
+
// does not give you: it leaves the tab order when closed, Escape closes it, and
|
|
5399
|
+
// focus moves in on open and back to the button that opened it on close.
|
|
5400
|
+
function menuIsOpen() {
|
|
5401
|
+
return !!document.getElementById('sidebar')?.classList.contains('open');
|
|
5402
|
+
}
|
|
5403
|
+
|
|
5404
|
+
function openMenu() {
|
|
5405
|
+
const sb = document.getElementById('sidebar');
|
|
5406
|
+
if (!sb) return;
|
|
5407
|
+
sb.classList.add('open');
|
|
5408
|
+
document.getElementById('sidebar-scrim')?.classList.add('open');
|
|
5409
|
+
document.getElementById('menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
5410
|
+
document.body.style.overflow = 'hidden';
|
|
5411
|
+
(sb.querySelector('.nav-item.active') || sb.querySelector('.nav-item'))?.focus();
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
// `restoreFocus` is false when the drawer closes because a tab was CHOSEN — the
|
|
5415
|
+
// user is looking at the panel they just picked, and yanking focus back to the
|
|
5416
|
+
// hamburger would undo the thing they came for. It is true when the drawer is
|
|
5417
|
+
// dismissed (Escape, scrim, the button), where the button is where focus was.
|
|
5418
|
+
function closeMenu(restoreFocus = true) {
|
|
5419
|
+
const sb = document.getElementById('sidebar');
|
|
5420
|
+
if (!sb || !sb.classList.contains('open')) return;
|
|
5421
|
+
sb.classList.remove('open');
|
|
5422
|
+
document.getElementById('sidebar-scrim')?.classList.remove('open');
|
|
5423
|
+
const btn = document.getElementById('menu-btn');
|
|
5424
|
+
btn?.setAttribute('aria-expanded', 'false');
|
|
5425
|
+
document.body.style.overflow = '';
|
|
5426
|
+
if (restoreFocus) btn?.focus();
|
|
5427
|
+
}
|
|
5428
|
+
|
|
5429
|
+
function toggleMenu() { menuIsOpen() ? closeMenu() : openMenu(); }
|
|
5430
|
+
|
|
5431
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && menuIsOpen()) closeMenu(); });
|
|
5432
|
+
|
|
5433
|
+
// Rotating a phone, or dragging a desktop window wider, un-applies the media
|
|
5434
|
+
// query — the drawer stops being a drawer while `overflow: hidden` is still on
|
|
5435
|
+
// the body, which is a page that will not scroll and no visible reason why.
|
|
5436
|
+
try {
|
|
5437
|
+
matchMedia('(min-width: 769px)').addEventListener('change', (e) => { if (e.matches) closeMenu(false); });
|
|
5438
|
+
} catch (_) { /* older Safari: no addEventListener on MediaQueryList — drawer still works */ }
|
|
5439
|
+
|
|
5020
5440
|
function switchTab(id, el) {
|
|
5021
5441
|
// `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
|
|
5022
5442
|
// Without this the call fell through to code that expects a nav element and
|
|
@@ -5024,6 +5444,7 @@ function switchTab(id, el) {
|
|
|
5024
5444
|
if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
|
|
5025
5445
|
|| document.querySelector('.nav-item[data-tab="dashboard"]');
|
|
5026
5446
|
currentTab = id;
|
|
5447
|
+
closeMenu(false); // choosing a destination is the end of using the menu
|
|
5027
5448
|
// sidebar nav
|
|
5028
5449
|
document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
|
|
5029
5450
|
t.classList.toggle('active', t.dataset.tab === id);
|
|
@@ -5463,7 +5884,6 @@ function renderAgentProfile(p) {
|
|
|
5463
5884
|
${p.retired
|
|
5464
5885
|
? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
|
|
5465
5886
|
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent</button>`}
|
|
5466
|
-
<button type="button" disabled title="Opening external files is not yet wired up">Open prompt file</button>
|
|
5467
5887
|
</div>
|
|
5468
5888
|
<div style="font-size:11px;color:var(--text3);margin-top:8px">
|
|
5469
5889
|
File: <code>${esc(p.file_path)}</code>
|