great-cto 2.97.0 → 2.98.1
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.
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.98.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -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,13 @@ 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
|
+
|
|
533
568
|
.gate-approve { color: var(--status-review); border-color: rgba(22, 163, 74, 0.35); }
|
|
534
569
|
.gate-approve:hover { background: rgba(22, 163, 74, 0.08); border-color: var(--status-review); }
|
|
535
570
|
/* Agent runner (launch control) */
|
|
@@ -718,7 +753,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
718
753
|
.toast code { background: rgba(255,255,255,0.1); padding: 1px 4px; border-radius: 3px; }
|
|
719
754
|
.pl-stage { cursor: pointer; transition: background 0.12s; }
|
|
720
755
|
.pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
|
|
721
|
-
.card-selected { outline: 2px solid var(--
|
|
756
|
+
.card-selected { outline: 2px solid var(--focus-ring); outline-offset: -1px; }
|
|
722
757
|
#hotkey-modal {
|
|
723
758
|
position: fixed; inset: 0; z-index: 10000;
|
|
724
759
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -1202,6 +1237,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1202
1237
|
}
|
|
1203
1238
|
.cost-cell .v {
|
|
1204
1239
|
font-family: var(--serif); font-weight: 500;
|
|
1240
|
+
font-variant-numeric: tabular-nums;
|
|
1205
1241
|
font-size: 26px; letter-spacing: -0.02em;
|
|
1206
1242
|
color: var(--text); line-height: 1.1;
|
|
1207
1243
|
}
|
|
@@ -1544,9 +1580,21 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1544
1580
|
.metric-card.purple::before { background: #7c3aed; }
|
|
1545
1581
|
.metric-num {
|
|
1546
1582
|
font-family: var(--serif); font-weight: 500;
|
|
1583
|
+
/* The board is live (SSE). In a proportional face a 1 is narrower than an 8,
|
|
1584
|
+
so a figure that ticks over re-flows its own width and the number appears to
|
|
1585
|
+
breathe. Tabular figures cost nothing here and stop it. */
|
|
1586
|
+
font-variant-numeric: tabular-nums;
|
|
1547
1587
|
font-size: 52px; line-height: 1;
|
|
1548
1588
|
letter-spacing: -0.02em;
|
|
1549
1589
|
}
|
|
1590
|
+
/* An absence is rendered quietly and never silently — the glyph stays out of the
|
|
1591
|
+
way, the reason is available on hover and to a screen reader. Sized down from
|
|
1592
|
+
whatever it sits in, so a missing hero figure does not shout at 52px. */
|
|
1593
|
+
.absent {
|
|
1594
|
+
font-family: var(--mono); font-size: 0.55em;
|
|
1595
|
+
color: var(--text3); letter-spacing: 0.04em;
|
|
1596
|
+
cursor: help; border-bottom: 1px dotted var(--border-strong);
|
|
1597
|
+
}
|
|
1550
1598
|
.metric-num small {
|
|
1551
1599
|
font-size: 22px; color: var(--text2);
|
|
1552
1600
|
margin-left: 4px; font-weight: 400;
|
|
@@ -2165,6 +2213,107 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2165
2213
|
@media (max-width:480px) {
|
|
2166
2214
|
.chip-filter-bar { flex-direction: column; align-items: stretch; }
|
|
2167
2215
|
}
|
|
2216
|
+
@media (max-width: 768px) {
|
|
2217
|
+
.sidebar {
|
|
2218
|
+
position: fixed; top: 0; left: 0; bottom: 0;
|
|
2219
|
+
z-index: 40;
|
|
2220
|
+
transform: translateX(-100%);
|
|
2221
|
+
visibility: hidden;
|
|
2222
|
+
/* Visibility is switched, never eased. Transitioning it over the same 200ms
|
|
2223
|
+
as the transform flips it at the halfway mark, so the drawer is invisible
|
|
2224
|
+
for the first half of its own slide-in — and, worse, still unfocusable at
|
|
2225
|
+
the instant openMenu() tries to move focus into it, which silently left
|
|
2226
|
+
focus on the button and the menu unreachable by keyboard. On the way out
|
|
2227
|
+
it waits for the slide to finish (`0s linear 200ms`), so the panel does
|
|
2228
|
+
not vanish mid-animation. */
|
|
2229
|
+
transition: transform 200ms ease-out, visibility 0s linear 200ms;
|
|
2230
|
+
box-shadow: var(--drawer-shadow);
|
|
2231
|
+
}
|
|
2232
|
+
.sidebar.open {
|
|
2233
|
+
transform: none; visibility: visible;
|
|
2234
|
+
transition: transform 200ms ease-out, visibility 0s;
|
|
2235
|
+
}
|
|
2236
|
+
.sidebar-scrim { display: block; }
|
|
2237
|
+
.sidebar-scrim.open { opacity: 1; pointer-events: auto; }
|
|
2238
|
+
.menu-btn { display: inline-flex; }
|
|
2239
|
+
.topbar { padding: 0 10px; gap: 8px; }
|
|
2240
|
+
/* Raising every control to 44px gave the actions 223px of a 375px bar and
|
|
2241
|
+
crushed the breadcrumb to "g… / B." — present, unreadable, worse than absent.
|
|
2242
|
+
The project name is already at the top of the drawer and the tab name is the
|
|
2243
|
+
row you tapped to get here, so the phone keeps only where you are. */
|
|
2244
|
+
.topbar .crumbs { min-width: 0; overflow: hidden; white-space: nowrap; }
|
|
2245
|
+
.topbar .crumbs > span { overflow: hidden; text-overflow: ellipsis; }
|
|
2246
|
+
.topbar .crumbs .proj-icon,
|
|
2247
|
+
.topbar .crumbs .sep,
|
|
2248
|
+
.topbar .crumbs #crumb-project { display: none; }
|
|
2249
|
+
.topbar .crumbs .here { color: var(--text); font-weight: 600; }
|
|
2250
|
+
.topbar-actions { flex-shrink: 0; }
|
|
2251
|
+
/* The search box is the one topbar item that can give up its width; ⌘K still
|
|
2252
|
+
reaches it, and on a phone there is no ⌘K but there is the Search tab. */
|
|
2253
|
+
.topbar .search-box { display: none; }
|
|
2254
|
+
.topbar .task-count { display: none; }
|
|
2255
|
+
.topbar-actions { gap: 6px; }
|
|
2256
|
+
/* Icon-only, with the name moved to aria-label/title rather than deleted. */
|
|
2257
|
+
.btn-label { display: none; }
|
|
2258
|
+
|
|
2259
|
+
/* The row's own arithmetic, measured rather than eyeballed: the page gives
|
|
2260
|
+
64px to its side padding and the row another 30px to border and padding,
|
|
2261
|
+
leaving 281px for the tracks. The fixed ones — id 110, two 14px gaps, and a
|
|
2262
|
+
44px Approve/Reject pair at about 156 — come to 294. The 1fr title column
|
|
2263
|
+
resolves to ZERO and the row overflows its own border box.
|
|
2264
|
+
Raising the buttons to a real touch target is what finished it off: at 24px
|
|
2265
|
+
they fit, at 44px they do not. Fixing one defect deepened its neighbour.
|
|
2266
|
+
So on a phone the row stops being three columns and becomes three rows:
|
|
2267
|
+
title first at full width, the id demoted to a meta line, the actions
|
|
2268
|
+
spanning the bottom. CSS only — the markup this renders from is shared with
|
|
2269
|
+
the desktop layout, and the desktop layout is fine. */
|
|
2270
|
+
.inbox-page { padding: 16px 16px 48px; }
|
|
2271
|
+
.inbox-row {
|
|
2272
|
+
display: flex; flex-wrap: wrap;
|
|
2273
|
+
gap: 8px 10px; align-items: baseline;
|
|
2274
|
+
}
|
|
2275
|
+
.inbox-row .ttl { order: 1; flex: 1 1 100%; font-size: 14px; }
|
|
2276
|
+
/* Two lines, wrapped — a phone has the vertical room it lacks sideways. */
|
|
2277
|
+
.inbox-row .ttl > :first-child { -webkit-line-clamp: 2; white-space: normal; }
|
|
2278
|
+
/* The id and the status share a line: stacked, each took a full row of its
|
|
2279
|
+
own and a four-line card said three things. */
|
|
2280
|
+
.inbox-row .id { order: 2; flex: 0 1 auto; padding-top: 0; }
|
|
2281
|
+
.inbox-row .actions { order: 3; margin-left: auto; }
|
|
2282
|
+
/* Except on a gate, where the line is a pair of 44px buttons and wants the
|
|
2283
|
+
whole width — the one row on this screen where the target size is the point. */
|
|
2284
|
+
.inbox-row .actions:has(.gate-btn) { flex: 1 0 100%; margin-left: 0; }
|
|
2285
|
+
.inbox-row .actions .gate-btn { flex: 1; }
|
|
2286
|
+
|
|
2287
|
+
/* Four columns of numbers do not fit in 375px; they were being clipped, which
|
|
2288
|
+
silently dropped the fourth stat rather than wrapping it. */
|
|
2289
|
+
.inbox-summary { grid-template-columns: repeat(2, 1fr); }
|
|
2290
|
+
.mp-secondary { grid-template-columns: repeat(2, 1fr); }
|
|
2291
|
+
.mp-hero { grid-template-columns: 1fr; }
|
|
2292
|
+
.mp-row { grid-template-columns: 1fr; }
|
|
2293
|
+
}
|
|
2294
|
+
@media (pointer: coarse) {
|
|
2295
|
+
/* A floor, not a list.
|
|
2296
|
+
Placed here, at the END of the stylesheet, for the same reason the
|
|
2297
|
+
phone-width block is: at equal specificity the later rule wins. Written
|
|
2298
|
+
higher up, its `.inbox-row .actions { gap: 12px }` lost to the base rule's
|
|
2299
|
+
4px declared 900 lines below it — so Approve and Reject sat 4px apart on a
|
|
2300
|
+
touch screen while a block that read as correct sat above saying 12.
|
|
2301
|
+
A declaration that loses is not a declaration.
|
|
2302
|
+
The first version of this block named .gate-btn and stopped there, because
|
|
2303
|
+
those were the buttons a review had pointed at. Two controls this same file
|
|
2304
|
+
had just added — the drawer's own hamburger at 40px and the topbar's icon
|
|
2305
|
+
buttons at ~32px — sat under the minimum from the day they were written,
|
|
2306
|
+
and nothing objected, because the rule was a list of what somebody had
|
|
2307
|
+
remembered rather than a property of the page.
|
|
2308
|
+
So: every control gets the floor, and the ones with something to say about
|
|
2309
|
+
their own size say it after. A button added tomorrow is covered by default;
|
|
2310
|
+
one that must stay small has to opt out in writing. */
|
|
2311
|
+
button, [role="button"], .nav-item, .toggle, summary { min-height: 44px; }
|
|
2312
|
+
.gate-btn, .inbox-row .actions .gate-btn { height: 44px; font-size: 13px; }
|
|
2313
|
+
.menu-btn { width: 44px; height: 44px; }
|
|
2314
|
+
.btn-bell { min-width: 44px; justify-content: center; }
|
|
2315
|
+
.gate-actions, .side-gate-actions, .inbox-row .actions { gap: 12px; }
|
|
2316
|
+
}
|
|
2168
2317
|
@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
2318
|
/* ── Scrollbar ────────────────────────────────────────────────────────────── */
|
|
2170
2319
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
@@ -2178,7 +2327,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2178
2327
|
<div class="board-bg" aria-hidden="true"></div>
|
|
2179
2328
|
|
|
2180
2329
|
<!-- Sidebar -->
|
|
2181
|
-
<aside class="sidebar">
|
|
2330
|
+
<aside class="sidebar" id="sidebar">
|
|
2182
2331
|
<div class="sidebar-brand">
|
|
2183
2332
|
<span class="brand-mark">
|
|
2184
2333
|
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
@@ -2208,7 +2357,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2208
2357
|
</div>
|
|
2209
2358
|
</div>
|
|
2210
2359
|
<nav class="nav">
|
|
2211
|
-
<div class="nav-item active" data-tab="inbox" onclick="switchTab('inbox', this)">
|
|
2360
|
+
<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
2361
|
<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
2362
|
<span>Inbox</span>
|
|
2214
2363
|
<span class="count" id="nav-inbox-count">0</span>
|
|
@@ -2246,9 +2395,15 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2246
2395
|
</div>
|
|
2247
2396
|
</aside>
|
|
2248
2397
|
|
|
2398
|
+
<div class="sidebar-scrim" id="sidebar-scrim" onclick="closeMenu()" aria-hidden="true"></div>
|
|
2399
|
+
|
|
2249
2400
|
<!-- Workspace -->
|
|
2250
2401
|
<div class="workspace">
|
|
2251
2402
|
<div class="topbar">
|
|
2403
|
+
<button class="menu-btn" id="menu-btn" type="button" aria-label="Menu"
|
|
2404
|
+
aria-controls="sidebar" aria-expanded="false" onclick="toggleMenu()">
|
|
2405
|
+
<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>
|
|
2406
|
+
</button>
|
|
2252
2407
|
<div class="crumbs">
|
|
2253
2408
|
<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
2409
|
<span id="crumb-project">—</span>
|
|
@@ -2264,7 +2419,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2264
2419
|
<span class="task-count" id="topbar-task-count">0 ISSUES</span>
|
|
2265
2420
|
<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
2421
|
<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>
|
|
2422
|
+
<span class="btn-label" style="font-size:12px;font-weight:500">Share</span>
|
|
2268
2423
|
<span id="share-nav-badge" class="share-badge off"><span class="share-badge-dot"></span>off</span>
|
|
2269
2424
|
</button>
|
|
2270
2425
|
<button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme">🌙</button>
|
|
@@ -2272,9 +2427,9 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2272
2427
|
<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
2428
|
<span class="btn-bell-badge" id="topbar-bell-badge"></span>
|
|
2274
2429
|
</button>
|
|
2275
|
-
<button class="btn-new" onclick="openNewIssueModal()">
|
|
2430
|
+
<button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
|
|
2276
2431
|
<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
|
|
2432
|
+
<span class="btn-label">New issue</span>
|
|
2278
2433
|
</button>
|
|
2279
2434
|
</div>
|
|
2280
2435
|
</div>
|
|
@@ -4636,6 +4791,28 @@ function cardHTML(t) {
|
|
|
4636
4791
|
</div>`;
|
|
4637
4792
|
}
|
|
4638
4793
|
|
|
4794
|
+
// What clicking Approve actually sets in motion, in the words of what it does.
|
|
4795
|
+
//
|
|
4796
|
+
// A confirm that only asks "are you sure?" teaches people to dismiss confirms,
|
|
4797
|
+
// so this one states consequences instead: the approval is appended to the
|
|
4798
|
+
// verdict log, the pipeline is woken so the next session dispatches the
|
|
4799
|
+
// following stage, and — when the share report is on — a public URL is
|
|
4800
|
+
// republished. That last line is the one worth reading: it leaves the machine,
|
|
4801
|
+
// and revoking the link later does not un-see what was already served.
|
|
4802
|
+
function approveConsequence(id) {
|
|
4803
|
+
const lines = [
|
|
4804
|
+
`Approve ${id}?`,
|
|
4805
|
+
'',
|
|
4806
|
+
'Records your approval in the verdict log and wakes the pipeline —',
|
|
4807
|
+
'the next session dispatches the following stage.',
|
|
4808
|
+
];
|
|
4809
|
+
if (shareState && shareState.enabled) {
|
|
4810
|
+
lines.push('', `Also republishes the public report at ${shareState.url || 'the share URL'} —`,
|
|
4811
|
+
'anyone holding that link sees the update.');
|
|
4812
|
+
}
|
|
4813
|
+
return lines.join('\n');
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4639
4816
|
async function gateAction(id, action, runAgent) {
|
|
4640
4817
|
let body;
|
|
4641
4818
|
if (action === 'reject') {
|
|
@@ -4643,6 +4820,16 @@ async function gateAction(id, action, runAgent) {
|
|
|
4643
4820
|
if (reason === null) return;
|
|
4644
4821
|
body = { action, reason, project: currentProject };
|
|
4645
4822
|
} else {
|
|
4823
|
+
// The gate is the human signature the pipeline waits on. Rejecting has
|
|
4824
|
+
// always asked for a reason and approving asked for nothing, so the cheaper
|
|
4825
|
+
// click was the one that advanced work nobody can un-advance — friction
|
|
4826
|
+
// pointing exactly the wrong way.
|
|
4827
|
+
//
|
|
4828
|
+
// Medium cost-of-undo (ADR-009): one confirm that NAMES the consequence, not
|
|
4829
|
+
// a typed-name ritual on a button pressed several times a day. The runAgent
|
|
4830
|
+
// path already shows an editable prompt that says what it will approve and
|
|
4831
|
+
// run, and is cancellable — a second dialog there would be pure friction.
|
|
4832
|
+
if (!runAgent && !confirm(approveConsequence(id))) return;
|
|
4646
4833
|
body = { action, project: currentProject };
|
|
4647
4834
|
if (runAgent) {
|
|
4648
4835
|
const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
|
|
@@ -4733,6 +4920,32 @@ async function updateTaskPriority(id, priority) {
|
|
|
4733
4920
|
}
|
|
4734
4921
|
}
|
|
4735
4922
|
|
|
4923
|
+
/* ── One glyph cannot mean five things ─────────────────────────────────────
|
|
4924
|
+
*
|
|
4925
|
+
* A single em dash was doing duty for at least five different facts on this
|
|
4926
|
+
* board: not loaded yet, measured and there is none, cannot be computed from
|
|
4927
|
+
* what we have, no verdict, no date. On a screen where the figures are money and
|
|
4928
|
+
* throughput, "we looked and it is zero" and "we could not look" are not the
|
|
4929
|
+
* same answer, and the reader was being asked to guess which one they had.
|
|
4930
|
+
*
|
|
4931
|
+
* Three kinds, because three is what actually occurs here. Vocabulary follows
|
|
4932
|
+
* the distinction statistical agencies draw between a value that is missing and
|
|
4933
|
+
* one that is not applicable; the wording is ours, the discipline is theirs.
|
|
4934
|
+
*
|
|
4935
|
+
* The caller always passes the specific reason. A generic tooltip that repeats
|
|
4936
|
+
* the glyph's category would add a hover and no information.
|
|
4937
|
+
*/
|
|
4938
|
+
const ABSENCE = {
|
|
4939
|
+
none: '—', // measured; the answer is that there is none
|
|
4940
|
+
uncomputable: 'n/a', // the inputs for this figure do not exist
|
|
4941
|
+
unloaded: '·', // no answer yet — the read has not landed
|
|
4942
|
+
};
|
|
4943
|
+
|
|
4944
|
+
function absent(kind, why) {
|
|
4945
|
+
const glyph = ABSENCE[kind] || ABSENCE.none;
|
|
4946
|
+
return `<span class="absent" title="${esc(why)}" aria-label="${esc(why)}">${glyph}</span>`;
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4736
4949
|
/* ── Dashboard ──────────────────────────────────────────────────────────── */
|
|
4737
4950
|
function renderDashboard(m) {
|
|
4738
4951
|
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
@@ -4743,6 +4956,18 @@ function renderDashboard(m) {
|
|
|
4743
4956
|
// tasks.md fallback with no dates) show a meaningless "—" despite real work.
|
|
4744
4957
|
// (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
|
|
4745
4958
|
const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
|
|
4959
|
+
// Three states, not two. `?? 0` folds "the payload has no tasks section" into
|
|
4960
|
+
// the same 0 as "zero tasks done", and the tile then rendered that 0 as an
|
|
4961
|
+
// absence — so a day-one project read "—" beside "+0 this week", the board
|
|
4962
|
+
// reporting a failure to measure something it had measured fine. The backend
|
|
4963
|
+
// always sends `tasks` (lib/metrics.mjs), so this is "—" only for a payload
|
|
4964
|
+
// that really is missing it.
|
|
4965
|
+
const doneKnown = m.tasks != null;
|
|
4966
|
+
const costKnown = m.cost != null;
|
|
4967
|
+
// NOT the same shape: avg_completion_min is a median over completions that
|
|
4968
|
+
// carry timestamps, and the backend emits 0 when there are none — so 0 here
|
|
4969
|
+
// means "could not be computed", not "took no time". "—" is the correct
|
|
4970
|
+
// reading; do not "fix" this into a literal 0 m.
|
|
4746
4971
|
const avgMin = m.avg_completion_min ?? 0;
|
|
4747
4972
|
const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
|
|
4748
4973
|
|
|
@@ -4765,9 +4990,11 @@ function renderDashboard(m) {
|
|
|
4765
4990
|
: (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
|
|
4766
4991
|
|
|
4767
4992
|
const hero = [
|
|
4768
|
-
{ v: done
|
|
4993
|
+
{ 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` },
|
|
4769
4994
|
{
|
|
4770
|
-
|
|
4995
|
+
// $0.00 with nothing spent yet is a measurement; "—" would claim we never
|
|
4996
|
+
// looked. The trend line underneath already says which of the two it is.
|
|
4997
|
+
v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
|
|
4771
4998
|
sub: '',
|
|
4772
4999
|
label: 'AI spend',
|
|
4773
5000
|
trend: llmTrend,
|
|
@@ -4784,7 +5011,7 @@ function renderDashboard(m) {
|
|
|
4784
5011
|
: {
|
|
4785
5012
|
// No MEASURED savings yet (no verdict cost data). Don't fabricate a
|
|
4786
5013
|
// multiplier from the time-based estimate — show it's not measured.
|
|
4787
|
-
v: '
|
|
5014
|
+
v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
|
|
4788
5015
|
sub: '',
|
|
4789
5016
|
label: 'Cost savings vs FTE',
|
|
4790
5017
|
trend: aiSpend > 0
|
|
@@ -4802,12 +5029,12 @@ function renderDashboard(m) {
|
|
|
4802
5029
|
|
|
4803
5030
|
const acc = m.acceptance || {};
|
|
4804
5031
|
const secondary = [
|
|
4805
|
-
{ v: avgMin ? `${avgMin}` : '
|
|
4806
|
-
{ v: m.qa?.pass_rate != null ? m.qa.pass_rate : '
|
|
5032
|
+
{ v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'm' : '', label: cycleStat },
|
|
5033
|
+
{ 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' },
|
|
4807
5034
|
// Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
|
|
4808
5035
|
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
4809
5036
|
// honest gap, not a zero.
|
|
4810
|
-
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : '
|
|
5037
|
+
{ 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' },
|
|
4811
5038
|
{ v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
|
|
4812
5039
|
{ v: m.security?.blocked ?? 0, sub: '', label: 'Open security blocks', cls: m.security?.blocked ? 'red' : 'green' },
|
|
4813
5040
|
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
@@ -5073,6 +5300,19 @@ function setNotifStatus(text, tone) {
|
|
|
5073
5300
|
|
|
5074
5301
|
async function toggleShare() {
|
|
5075
5302
|
const enabled = !document.getElementById('share-toggle').checked;
|
|
5303
|
+
// Only on the way ON. Switching sharing OFF is the safe direction and asking
|
|
5304
|
+
// there would train people to click through the dialog that matters. The card
|
|
5305
|
+
// already explains what publishing means, but a toggle can be flipped without
|
|
5306
|
+
// reading the card next to it, and revoking the link afterwards does not
|
|
5307
|
+
// un-see what was already served.
|
|
5308
|
+
if (enabled && !confirm(
|
|
5309
|
+
'Publish a public report for this project?\n\n' +
|
|
5310
|
+
'Anyone holding the link can read what shipped, the metrics and the activity,\n' +
|
|
5311
|
+
'with no sign-in. Approving a gate republishes it automatically.\n\n' +
|
|
5312
|
+
'Turning sharing off later revokes the link — it does not unpublish what has\n' +
|
|
5313
|
+
'already been read.')) {
|
|
5314
|
+
return;
|
|
5315
|
+
}
|
|
5076
5316
|
document.getElementById('share-loading').style.display = enabled ? 'flex' : 'none';
|
|
5077
5317
|
document.getElementById('share-url-section').style.display = 'none';
|
|
5078
5318
|
// BUG FIX: scope publish to the currently-selected project, not server's cwd
|
|
@@ -5207,6 +5447,51 @@ function closeSide() {
|
|
|
5207
5447
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide(); });
|
|
5208
5448
|
|
|
5209
5449
|
/* ── Tabs ───────────────────────────────────────────────────────────────── */
|
|
5450
|
+
/* ── The sidebar as a drawer on narrow screens ──────────────────────────── */
|
|
5451
|
+
//
|
|
5452
|
+
// Three things a drawer owes a keyboard, all of them things a transform alone
|
|
5453
|
+
// does not give you: it leaves the tab order when closed, Escape closes it, and
|
|
5454
|
+
// focus moves in on open and back to the button that opened it on close.
|
|
5455
|
+
function menuIsOpen() {
|
|
5456
|
+
return !!document.getElementById('sidebar')?.classList.contains('open');
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
function openMenu() {
|
|
5460
|
+
const sb = document.getElementById('sidebar');
|
|
5461
|
+
if (!sb) return;
|
|
5462
|
+
sb.classList.add('open');
|
|
5463
|
+
document.getElementById('sidebar-scrim')?.classList.add('open');
|
|
5464
|
+
document.getElementById('menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
5465
|
+
document.body.style.overflow = 'hidden';
|
|
5466
|
+
(sb.querySelector('.nav-item.active') || sb.querySelector('.nav-item'))?.focus();
|
|
5467
|
+
}
|
|
5468
|
+
|
|
5469
|
+
// `restoreFocus` is false when the drawer closes because a tab was CHOSEN — the
|
|
5470
|
+
// user is looking at the panel they just picked, and yanking focus back to the
|
|
5471
|
+
// hamburger would undo the thing they came for. It is true when the drawer is
|
|
5472
|
+
// dismissed (Escape, scrim, the button), where the button is where focus was.
|
|
5473
|
+
function closeMenu(restoreFocus = true) {
|
|
5474
|
+
const sb = document.getElementById('sidebar');
|
|
5475
|
+
if (!sb || !sb.classList.contains('open')) return;
|
|
5476
|
+
sb.classList.remove('open');
|
|
5477
|
+
document.getElementById('sidebar-scrim')?.classList.remove('open');
|
|
5478
|
+
const btn = document.getElementById('menu-btn');
|
|
5479
|
+
btn?.setAttribute('aria-expanded', 'false');
|
|
5480
|
+
document.body.style.overflow = '';
|
|
5481
|
+
if (restoreFocus) btn?.focus();
|
|
5482
|
+
}
|
|
5483
|
+
|
|
5484
|
+
function toggleMenu() { menuIsOpen() ? closeMenu() : openMenu(); }
|
|
5485
|
+
|
|
5486
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && menuIsOpen()) closeMenu(); });
|
|
5487
|
+
|
|
5488
|
+
// Rotating a phone, or dragging a desktop window wider, un-applies the media
|
|
5489
|
+
// query — the drawer stops being a drawer while `overflow: hidden` is still on
|
|
5490
|
+
// the body, which is a page that will not scroll and no visible reason why.
|
|
5491
|
+
try {
|
|
5492
|
+
matchMedia('(min-width: 769px)').addEventListener('change', (e) => { if (e.matches) closeMenu(false); });
|
|
5493
|
+
} catch (_) { /* older Safari: no addEventListener on MediaQueryList — drawer still works */ }
|
|
5494
|
+
|
|
5210
5495
|
function switchTab(id, el) {
|
|
5211
5496
|
// `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
|
|
5212
5497
|
// Without this the call fell through to code that expects a nav element and
|
|
@@ -5214,6 +5499,7 @@ function switchTab(id, el) {
|
|
|
5214
5499
|
if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
|
|
5215
5500
|
|| document.querySelector('.nav-item[data-tab="dashboard"]');
|
|
5216
5501
|
currentTab = id;
|
|
5502
|
+
closeMenu(false); // choosing a destination is the end of using the menu
|
|
5217
5503
|
// sidebar nav
|
|
5218
5504
|
document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
|
|
5219
5505
|
t.classList.toggle('active', t.dataset.tab === id);
|
|
@@ -5653,7 +5939,6 @@ function renderAgentProfile(p) {
|
|
|
5653
5939
|
${p.retired
|
|
5654
5940
|
? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
|
|
5655
5941
|
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent</button>`}
|
|
5656
|
-
<button type="button" disabled title="Opening external files is not yet wired up">Open prompt file</button>
|
|
5657
5942
|
</div>
|
|
5658
5943
|
<div style="font-size:11px;color:var(--text3);margin-top:8px">
|
|
5659
5944
|
File: <code>${esc(p.file_path)}</code>
|
package/package.json
CHANGED