great-cto 2.97.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.
@@ -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.97.0",
5
+ "version": "2.98.0",
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(--accent); background: var(--bg-card); }
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(--accent, #3b82f6); outline-offset: -1px; }
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>
@@ -4636,6 +4736,28 @@ function cardHTML(t) {
4636
4736
  </div>`;
4637
4737
  }
4638
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
+
4639
4761
  async function gateAction(id, action, runAgent) {
4640
4762
  let body;
4641
4763
  if (action === 'reject') {
@@ -4643,6 +4765,16 @@ async function gateAction(id, action, runAgent) {
4643
4765
  if (reason === null) return;
4644
4766
  body = { action, reason, project: currentProject };
4645
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;
4646
4778
  body = { action, project: currentProject };
4647
4779
  if (runAgent) {
4648
4780
  const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
@@ -4733,6 +4865,32 @@ async function updateTaskPriority(id, priority) {
4733
4865
  }
4734
4866
  }
4735
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
+
4736
4894
  /* ── Dashboard ──────────────────────────────────────────────────────────── */
4737
4895
  function renderDashboard(m) {
4738
4896
  const llmUsd = m.cost?.llm_usd ?? 0;
@@ -4743,6 +4901,18 @@ function renderDashboard(m) {
4743
4901
  // tasks.md fallback with no dates) show a meaningless "—" despite real work.
4744
4902
  // (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
4745
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.
4746
4916
  const avgMin = m.avg_completion_min ?? 0;
4747
4917
  const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
4748
4918
 
@@ -4765,9 +4935,11 @@ function renderDashboard(m) {
4765
4935
  : (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
4766
4936
 
4767
4937
  const hero = [
4768
- { v: done || '', sub: 'shipped', label: 'Tasks done', trend: `+${m.velocity?.this_week ?? 0} this week` },
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` },
4769
4939
  {
4770
- v: aiSpend > 0 ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : '—',
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'),
4771
4943
  sub: '',
4772
4944
  label: 'AI spend',
4773
4945
  trend: llmTrend,
@@ -4784,7 +4956,7 @@ function renderDashboard(m) {
4784
4956
  : {
4785
4957
  // No MEASURED savings yet (no verdict cost data). Don't fabricate a
4786
4958
  // multiplier from the time-based estimate — show it's not measured.
4787
- v: '',
4959
+ v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
4788
4960
  sub: '',
4789
4961
  label: 'Cost savings vs FTE',
4790
4962
  trend: aiSpend > 0
@@ -4802,12 +4974,12 @@ function renderDashboard(m) {
4802
4974
 
4803
4975
  const acc = m.acceptance || {};
4804
4976
  const secondary = [
4805
- { v: avgMin ? `${avgMin}` : '', sub: avgMin ? 'm' : '', label: cycleStat },
4806
- { v: m.qa?.pass_rate != null ? m.qa.pass_rate : '', sub: m.qa?.pass_rate != null ? '%' : '', label: m.qa?.pass_rate != null ? 'QA pass rate' : 'QA — run /qa', cls: 'green' },
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' },
4807
4979
  // Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
4808
4980
  // how often work bounced. '—' when nothing was accepted in the window — an
4809
4981
  // honest gap, not a zero.
4810
- { v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : '', sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
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' },
4811
4983
  { v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
4812
4984
  { v: m.security?.blocked ?? 0, sub: '', label: 'Open security blocks', cls: m.security?.blocked ? 'red' : 'green' },
4813
4985
  { v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
@@ -5073,6 +5245,19 @@ function setNotifStatus(text, tone) {
5073
5245
 
5074
5246
  async function toggleShare() {
5075
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
+ }
5076
5261
  document.getElementById('share-loading').style.display = enabled ? 'flex' : 'none';
5077
5262
  document.getElementById('share-url-section').style.display = 'none';
5078
5263
  // BUG FIX: scope publish to the currently-selected project, not server's cwd
@@ -5207,6 +5392,51 @@ function closeSide() {
5207
5392
  document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide(); });
5208
5393
 
5209
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
+
5210
5440
  function switchTab(id, el) {
5211
5441
  // `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
5212
5442
  // Without this the call fell through to code that expects a nav element and
@@ -5214,6 +5444,7 @@ function switchTab(id, el) {
5214
5444
  if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
5215
5445
  || document.querySelector('.nav-item[data-tab="dashboard"]');
5216
5446
  currentTab = id;
5447
+ closeMenu(false); // choosing a destination is the end of using the menu
5217
5448
  // sidebar nav
5218
5449
  document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
5219
5450
  t.classList.toggle('active', t.dataset.tab === id);
@@ -5653,7 +5884,6 @@ function renderAgentProfile(p) {
5653
5884
  ${p.retired
5654
5885
  ? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
5655
5886
  : `<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
5887
  </div>
5658
5888
  <div style="font-size:11px;color:var(--text3);margin-top:8px">
5659
5889
  File: <code>${esc(p.file_path)}</code>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.97.0",
3
+ "version": "2.98.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",