@jhizzard/termdeck 0.10.3 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
5
5
  "bin": {
6
6
  "termdeck": "./packages/cli/src/index.js"
@@ -34,6 +34,7 @@ const {
34
34
  dotenv,
35
35
  supabaseUrl: urlHelper,
36
36
  migrations,
37
+ migrationTemplating,
37
38
  pgRunner,
38
39
  preconditions
39
40
  } = require(SETUP_DIR);
@@ -428,26 +429,53 @@ async function testFunction(projectRef, secrets, dryRun) {
428
429
  return true;
429
430
  }
430
431
 
432
+ // Sprint 42 T3: bundle of cron-schedule migrations whose `<project-ref>`
433
+ // placeholder must be substituted at apply-time. Both are idempotent
434
+ // (cron.unschedule + cron.schedule), so applying in sequence is safe even
435
+ // when one was already installed. Pre-Sprint 42, only 002 was applied —
436
+ // migration 003 (graph-inference-tick) shipped bundled but unsubstituted
437
+ // and unscheduled, which is part of why Sprint 38 close-out left the
438
+ // graph-inference cron disabled.
439
+ const SCHEDULE_MIGRATIONS = [
440
+ { matcher: /002.*pg_cron/, label: '002_pg_cron_schedule (rumen-tick)' },
441
+ { matcher: /003.*graph_inference/, label: '003_graph_inference_schedule (graph-inference-tick)' }
442
+ ];
443
+
431
444
  async function applySchedule(projectRef, secrets, dryRun) {
432
- step('Applying pg_cron schedule (every 15 minutes)...');
445
+ step('Applying pg_cron schedules (rumen-tick + graph-inference-tick)...');
433
446
  if (dryRun) { ok('(dry-run)'); return true; }
434
447
 
435
448
  const files = migrations.listRumenMigrations();
436
- const scheduleFile = files.find((f) => /002.*pg_cron/.test(path.basename(f)));
437
- if (!scheduleFile) { fail('bundled 002_pg_cron_schedule.sql is missing'); return false; }
438
-
439
- const raw = migrations.readFile(scheduleFile);
440
- // Substitute the project ref into the schedule body. The bundled migration
441
- // ships with the placeholder `<project-ref>` per Rumen's deploy docs; we
442
- // also accept `{{PROJECT_REF}}` for robustness.
443
- const substituted = raw
444
- .replace(/<project-ref>/g, projectRef)
445
- .replace(/\{\{PROJECT_REF\}\}/g, projectRef);
446
-
447
- // The shipped migration uses Supabase Vault (`vault.decrypted_secrets`) to
448
- // pull the service-role key. If the user hasn't stored the key in Vault the
449
- // cron call will fail. We leave that as a post-install step and print a
450
- // reminder below.
449
+ const planned = [];
450
+ for (const { matcher, label } of SCHEDULE_MIGRATIONS) {
451
+ const file = files.find((f) => matcher.test(path.basename(f)));
452
+ if (!file) { fail(`bundled ${label} is missing`); return false; }
453
+ planned.push({ file, label });
454
+ }
455
+
456
+ // Substitute the project ref into each schedule body. Both bundled
457
+ // migrations ship with the `<project-ref>` placeholder per Rumen's
458
+ // deploy docs; the helper also accepts `{{PROJECT_REF}}` for robustness
459
+ // and refuses to ship an unsubstituted placeholder to the database.
460
+ const substituted = [];
461
+ try {
462
+ for (const { file, label } of planned) {
463
+ const raw = migrations.readFile(file);
464
+ substituted.push({
465
+ sql: migrationTemplating.applyTemplating(raw, { projectRef }),
466
+ label
467
+ });
468
+ }
469
+ } catch (err) {
470
+ fail(err.message);
471
+ return false;
472
+ }
473
+
474
+ // The shipped migrations use Supabase Vault (`vault.decrypted_secrets`)
475
+ // to pull the service-role keys (`rumen_service_role_key` for 002 and
476
+ // `graph_inference_service_role_key` for 003). If a key isn't stored in
477
+ // Vault the corresponding cron call will fail at runtime. We leave that
478
+ // as a post-install step and print a reminder below.
451
479
 
452
480
  let client;
453
481
  try {
@@ -457,20 +485,21 @@ async function applySchedule(projectRef, secrets, dryRun) {
457
485
  return false;
458
486
  }
459
487
  try {
460
- // Run the substituted SQL directly rather than applying the original file.
461
- try {
462
- await pgRunner.run(client, substituted);
463
- ok();
464
- return true;
465
- } catch (err) {
466
- fail(err.message);
467
- process.stderr.write(
468
- '\nThe schedule SQL failed the most common cause is that pg_cron or pg_net\n' +
469
- 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
470
- '→ Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
471
- );
472
- return false;
488
+ for (const { sql, label } of substituted) {
489
+ try {
490
+ await pgRunner.run(client, sql);
491
+ } catch (err) {
492
+ fail(`${label}: ${err.message}`);
493
+ process.stderr.write(
494
+ '\nThe schedule SQL failed — the most common cause is that pg_cron or pg_net\n' +
495
+ 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
496
+ ' Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
497
+ );
498
+ return false;
499
+ }
473
500
  }
501
+ ok();
502
+ return true;
474
503
  } finally {
475
504
  try { await client.end(); } catch (_err) { /* ignore */ }
476
505
  }
@@ -91,6 +91,65 @@
91
91
  setupGuideRail();
92
92
  }
93
93
 
94
+ // ===== Drag/drop reorder of PTY panels (Sprint 42 T4) =====
95
+ // The grip handle in panel-header-left flips draggable=true on mousedown
96
+ // so an accidental drag inside the xterm region never fires. Drop
97
+ // position is determined by cursor x within the target panel — left half
98
+ // inserts before, right half inserts after — so reordering matches the
99
+ // intent in any grid layout (1x2, 2x2, 2x4, etc.). DOM reorder only;
100
+ // session.creation-order remains canonical for Alt+1…9 and panel-index.
101
+ function setupPanelDragDrop(panel) {
102
+ const handle = panel.querySelector('.panel-drag-handle');
103
+ if (!handle) return;
104
+
105
+ handle.addEventListener('mousedown', () => { panel.draggable = true; });
106
+ // Mouse leaves handle without a drag starting → reset
107
+ handle.addEventListener('mouseleave', () => {
108
+ if (!panel.classList.contains('dragging')) panel.draggable = false;
109
+ });
110
+
111
+ panel.addEventListener('dragstart', (e) => {
112
+ if (!panel.draggable) { e.preventDefault(); return; }
113
+ try { e.dataTransfer.effectAllowed = 'move'; } catch (_e) {}
114
+ try { e.dataTransfer.setData('text/plain', panel.id); } catch (_e) {}
115
+ panel.classList.add('dragging');
116
+ });
117
+
118
+ panel.addEventListener('dragend', () => {
119
+ panel.classList.remove('dragging');
120
+ panel.draggable = false;
121
+ document.querySelectorAll('.term-panel.drag-over').forEach((p) => p.classList.remove('drag-over'));
122
+ });
123
+
124
+ panel.addEventListener('dragover', (e) => {
125
+ const dragging = document.querySelector('.term-panel.dragging');
126
+ if (!dragging || dragging === panel) return;
127
+ e.preventDefault();
128
+ try { e.dataTransfer.dropEffect = 'move'; } catch (_e) {}
129
+ panel.classList.add('drag-over');
130
+ });
131
+
132
+ panel.addEventListener('dragleave', (e) => {
133
+ // Only clear when leaving the panel entirely (not entering a child).
134
+ if (!panel.contains(e.relatedTarget)) panel.classList.remove('drag-over');
135
+ });
136
+
137
+ panel.addEventListener('drop', (e) => {
138
+ e.preventDefault();
139
+ panel.classList.remove('drag-over');
140
+ const draggedId = (() => {
141
+ try { return e.dataTransfer.getData('text/plain'); } catch (_e) { return ''; }
142
+ })();
143
+ const dragged = draggedId
144
+ ? document.getElementById(draggedId)
145
+ : document.querySelector('.term-panel.dragging');
146
+ if (!dragged || dragged === panel) return;
147
+ const rect = panel.getBoundingClientRect();
148
+ const dropAfter = (e.clientX - rect.left) > rect.width / 2;
149
+ panel.parentNode.insertBefore(dragged, dropAfter ? panel.nextSibling : panel);
150
+ });
151
+ }
152
+
94
153
  // ===== Create Terminal Panel =====
95
154
  function createTerminalPanel(sessionData) {
96
155
  const id = sessionData.id;
@@ -128,6 +187,7 @@
128
187
  panel.innerHTML = `
129
188
  <div class="panel-header">
130
189
  <div class="panel-header-left">
190
+ <span class="panel-drag-handle" title="Drag to reorder">⋮⋮</span>
131
191
  <span class="status-dot" id="dot-${id}" style="background:${getStatusColor(meta.status)}"></span>
132
192
  <span class="panel-type">${getTypeLabel(meta.type)}</span>
133
193
  ${meta.project ? `<span class="panel-project ${projClass}">${meta.project}</span>` : ''}
@@ -193,6 +253,11 @@
193
253
 
194
254
  document.getElementById('termGrid').appendChild(panel);
195
255
 
256
+ // Sprint 42 T4: drag/drop reorder. Inject identifier is the session
257
+ // UUID, so DOM reorder is purely visual — Alt+1…9 (creation-order),
258
+ // /api/sessions/:id/input, and reply-form targets are unaffected.
259
+ setupPanelDragDrop(panel);
260
+
196
261
  // Create xterm.js instance
197
262
  const terminal = new Terminal({
198
263
  fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace",
@@ -265,6 +330,15 @@
265
330
  if (typeof updateRagIndicator === 'function') updateRagIndicator();
266
331
  }
267
332
  break;
333
+ case 'projects_changed':
334
+ // Sprint 42 T4: server broadcasts on POST/DELETE /api/projects.
335
+ // Sync the in-memory projects map and re-render the dropdown so
336
+ // other open dashboard tabs stay consistent without a refresh.
337
+ if (msg.projects && state.config) {
338
+ state.config.projects = msg.projects;
339
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
340
+ }
341
+ break;
268
342
  }
269
343
  } catch (err) { console.error('[client] ws message parse failed:', err); }
270
344
  };
@@ -1228,6 +1302,17 @@
1228
1302
  state.focusedId = id;
1229
1303
  }
1230
1304
 
1305
+ // Transfer xterm keyboard focus to the focused panel — without this,
1306
+ // the CSS class is the only thing that changed and keystrokes still
1307
+ // go to whichever element had DOM focus before (often the launcher
1308
+ // input, which submits a NEW terminal on Enter, or the previously
1309
+ // focused panel — leading to "easy to put wrong response into a
1310
+ // chat" reports). Mirrors the focus transfer in focusSessionById.
1311
+ const entry = state.sessions.get(id);
1312
+ if (entry && entry.terminal) {
1313
+ try { entry.terminal.focus(); } catch (err) { /* ignore */ }
1314
+ }
1315
+
1231
1316
  // Re-fit all visible terminals
1232
1317
  requestAnimationFrame(() => fitAll());
1233
1318
  }
@@ -1270,6 +1355,14 @@
1270
1355
  if (typeof updateRagIndicator === 'function') updateRagIndicator();
1271
1356
  }
1272
1357
  break;
1358
+ case 'projects_changed':
1359
+ // Sprint 42 T4: parity with main WS handler. Project add/remove
1360
+ // broadcasts arrive on every ws client; idempotent.
1361
+ if (msg.projects && state.config) {
1362
+ state.config.projects = msg.projects;
1363
+ if (typeof rebuildProjectDropdown === 'function') rebuildProjectDropdown();
1364
+ }
1365
+ break;
1273
1366
  }
1274
1367
  } catch (err) { console.error('[client] reconnect ws message failed:', err); }
1275
1368
  };
@@ -1541,6 +1634,104 @@
1541
1634
  }
1542
1635
  }
1543
1636
 
1637
+ // ===== Remove Project modal (Sprint 42 T4) =====
1638
+ // Removes a project from ~/.termdeck/config.yaml. Files on disk at the
1639
+ // project's `path` are NEVER touched — the modal copy makes that explicit
1640
+ // so users don't fear data loss. 409 from the server (live PTY sessions
1641
+ // for that project) prompts the user with a force-override.
1642
+ function openRemoveProjectModal() {
1643
+ const modal = document.getElementById('removeProjectModal');
1644
+ const sel = document.getElementById('rpmSelect');
1645
+ sel.innerHTML = '<option value="">— pick a project —</option>';
1646
+ for (const name of Object.keys(state.config.projects || {})) {
1647
+ const opt = document.createElement('option');
1648
+ opt.value = name;
1649
+ opt.textContent = name;
1650
+ sel.appendChild(opt);
1651
+ }
1652
+ sel.value = '';
1653
+ document.getElementById('rpmConfirm').disabled = true;
1654
+ document.getElementById('rpmConfirm').dataset.force = '';
1655
+ document.getElementById('rpmConfirm').textContent = 'remove project';
1656
+ const warn = document.getElementById('rpmWarning');
1657
+ warn.hidden = true;
1658
+ warn.textContent = '';
1659
+ setRpmStatus('', null);
1660
+ modal.classList.add('open');
1661
+ setTimeout(() => sel.focus(), 50);
1662
+ }
1663
+
1664
+ function closeRemoveProjectModal() {
1665
+ document.getElementById('removeProjectModal').classList.remove('open');
1666
+ }
1667
+
1668
+ function setRpmStatus(msg, kind) {
1669
+ const el = document.getElementById('rpmStatus');
1670
+ if (!el) return;
1671
+ el.textContent = msg || '';
1672
+ el.classList.remove('error', 'ok');
1673
+ if (kind) el.classList.add(kind);
1674
+ }
1675
+
1676
+ function onRpmSelectChange() {
1677
+ const name = document.getElementById('rpmSelect').value;
1678
+ const btn = document.getElementById('rpmConfirm');
1679
+ btn.disabled = !name;
1680
+ btn.dataset.force = '';
1681
+ btn.textContent = name ? `remove "${name}"` : 'remove project';
1682
+ const warn = document.getElementById('rpmWarning');
1683
+ warn.hidden = true;
1684
+ warn.textContent = '';
1685
+ setRpmStatus('', null);
1686
+ }
1687
+
1688
+ async function submitRemoveProject() {
1689
+ const name = document.getElementById('rpmSelect').value;
1690
+ if (!name) return;
1691
+ const btn = document.getElementById('rpmConfirm');
1692
+ const force = btn.dataset.force === 'true';
1693
+ btn.disabled = true;
1694
+ setRpmStatus(force ? 'Removing (with force)…' : 'Removing…', null);
1695
+
1696
+ try {
1697
+ const url = `${API}/api/projects/${encodeURIComponent(name)}${force ? '?force=true' : ''}`;
1698
+ const res = await fetch(url, { method: 'DELETE' });
1699
+ const text = await res.text();
1700
+ let body = {};
1701
+ try { body = JSON.parse(text); } catch { body = { error: text }; }
1702
+
1703
+ if (res.status === 409) {
1704
+ const live = body.liveSessions || 0;
1705
+ const warn = document.getElementById('rpmWarning');
1706
+ warn.hidden = false;
1707
+ warn.innerHTML =
1708
+ `<strong>"${name}" has ${live} live PTY session${live === 1 ? '' : 's'}.</strong> ` +
1709
+ `Closing those terminals first is recommended. ` +
1710
+ `Or click <em>remove anyway</em> to force removal — terminals stay open but lose their project tag in config.yaml.`;
1711
+ btn.dataset.force = 'true';
1712
+ btn.textContent = 'remove anyway';
1713
+ btn.disabled = false;
1714
+ setRpmStatus('', null);
1715
+ return;
1716
+ }
1717
+
1718
+ if (!res.ok) {
1719
+ setRpmStatus(`Failed: ${body.error || res.statusText}`, 'error');
1720
+ btn.disabled = false;
1721
+ return;
1722
+ }
1723
+
1724
+ // Success — sync in-memory config + dropdown.
1725
+ state.config.projects = body.projects || {};
1726
+ rebuildProjectDropdown();
1727
+ setRpmStatus(`Removed "${name}" ✓ (files on disk untouched)`, 'ok');
1728
+ setTimeout(() => { closeRemoveProjectModal(); }, 900);
1729
+ } catch (err) {
1730
+ setRpmStatus(`Failed: ${err.message || err}`, 'error');
1731
+ btn.disabled = false;
1732
+ }
1733
+ }
1734
+
1544
1735
  // ===== Orchestration preview modal (Sprint 37 T3) =====
1545
1736
  // The preview button next to the project select shows what
1546
1737
  // `termdeck init --project <name>` would create for the currently
@@ -3703,6 +3894,16 @@
3703
3894
  if (e.key === 'Escape') { e.preventDefault(); closeAddProjectModal(); }
3704
3895
  });
3705
3896
 
3897
+ // Remove-project modal wiring (Sprint 42 T4)
3898
+ document.getElementById('btnRemoveProject').addEventListener('click', openRemoveProjectModal);
3899
+ document.getElementById('rpmCancel').addEventListener('click', closeRemoveProjectModal);
3900
+ document.getElementById('rpmConfirm').addEventListener('click', submitRemoveProject);
3901
+ document.getElementById('rpmSelect').addEventListener('change', onRpmSelectChange);
3902
+ document.querySelector('#removeProjectModal .remove-project-backdrop').addEventListener('click', closeRemoveProjectModal);
3903
+ document.getElementById('removeProjectModal').addEventListener('keydown', (e) => {
3904
+ if (e.key === 'Escape') { e.preventDefault(); closeRemoveProjectModal(); }
3905
+ });
3906
+
3706
3907
  // Orchestration preview modal wiring (Sprint 37 T3)
3707
3908
  document.getElementById('btnPreviewProject').addEventListener('click', openPreviewModal);
3708
3909
  document.getElementById('promptProject').addEventListener('change', syncPreviewButton);
@@ -3759,11 +3960,24 @@
3759
3960
 
3760
3961
  // Keyboard shortcuts
3761
3962
  document.addEventListener('keydown', (e) => {
3762
- // Tour has priority: Esc exits, ArrowRight/Enter advances, ArrowLeft back
3963
+ // Tour has priority: Esc exits, ArrowRight/Enter advances, ArrowLeft back.
3964
+ // BUT: never swallow Enter/Arrow keys when the user is typing into a
3965
+ // terminal panel or any input/textarea — otherwise terminal Enter
3966
+ // (Claude Code / shell submit) gets eaten by the tour and the user
3967
+ // ends up advancing tour steps when they meant to send a message.
3968
+ // Brad's 2026-04-28 panel-UX report: "Hitting enter from full screen
3969
+ // goes to matrix again" matched this pathway when the v0.10.0 tour
3970
+ // re-fired post-upgrade.
3763
3971
  if (tourState.active) {
3764
- if (e.key === 'Escape') { e.preventDefault(); endTour(); return; }
3765
- if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); nextTourStep(); return; }
3766
- if (e.key === 'ArrowLeft') { e.preventDefault(); prevTourStep(); return; }
3972
+ const tgt = e.target;
3973
+ const tag = tgt?.tagName || '';
3974
+ const inEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable;
3975
+ const inTerminal = tgt?.closest && tgt.closest('.term-panel');
3976
+ if (!inEditable && !inTerminal) {
3977
+ if (e.key === 'Escape') { e.preventDefault(); endTour(); return; }
3978
+ if (e.key === 'ArrowRight' || e.key === 'Enter') { e.preventDefault(); nextTourStep(); return; }
3979
+ if (e.key === 'ArrowLeft') { e.preventDefault(); prevTourStep(); return; }
3980
+ }
3767
3981
  }
3768
3982
  // Ctrl+Shift+N → new terminal
3769
3983
  if (e.ctrlKey && e.shiftKey && e.key === 'N') {
@@ -135,6 +135,7 @@
135
135
  <option value="">no project</option>
136
136
  </select>
137
137
  <button class="prompt-add-project" id="btnAddProject" title="Add a new project">+</button>
138
+ <button class="prompt-remove-project" id="btnRemoveProject" title="Remove a project (files on disk are untouched)">−</button>
138
139
  <button class="prompt-preview-project" id="btnPreviewProject" title="Preview orchestration scaffolding for the selected project" disabled>preview</button>
139
140
  <button class="prompt-launch" id="promptLaunch">launch</button>
140
141
  </div>
@@ -169,6 +170,29 @@
169
170
  </div>
170
171
  </div>
171
172
 
173
+ <!-- Remove-project modal (Sprint 42 T4). Hidden by default. -->
174
+ <div class="remove-project-modal" id="removeProjectModal" role="dialog" aria-modal="true" aria-labelledby="rpmTitle">
175
+ <div class="remove-project-backdrop"></div>
176
+ <div class="remove-project-card">
177
+ <h3 id="rpmTitle">Remove a project</h3>
178
+ <p class="rpm-help">
179
+ This unregisters the project from <code>~/.termdeck/config.yaml</code>.
180
+ <strong>Files on disk are untouched</strong> — your source code, git history,
181
+ and everything at the project's path stay exactly where they are.
182
+ </p>
183
+ <label>
184
+ <span>Project to remove</span>
185
+ <select id="rpmSelect"><option value="">— pick a project —</option></select>
186
+ </label>
187
+ <div class="rpm-warning" id="rpmWarning" hidden></div>
188
+ <div class="rpm-status" id="rpmStatus"></div>
189
+ <div class="rpm-actions">
190
+ <button class="rpm-cancel" id="rpmCancel">cancel</button>
191
+ <button class="rpm-confirm" id="rpmConfirm" disabled>remove project</button>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
172
196
  <!-- Orchestration-preview modal (Sprint 37 T3). Hidden by default. -->
173
197
  <div class="preview-project-modal" id="previewProjectModal" role="dialog" aria-modal="true" aria-labelledby="ppmTitle">
174
198
  <div class="preview-project-backdrop"></div>
@@ -297,7 +297,13 @@
297
297
  /* ===== MAIN GRID ===== */
298
298
  .grid-container {
299
299
  flex: 1;
300
- padding: 6px;
300
+ /* Right padding of 38px reserves the 32px guide-rail collapsed strip
301
+ (position:fixed; right:0) plus 6px breathing. Without this the
302
+ rightmost panel's close (×) button sits underneath the guide
303
+ toggle and becomes unclickable. The expanded-guide state keeps
304
+ its own backdrop/shadow over panels — that's pre-existing
305
+ behavior and not affected by this padding. */
306
+ padding: 6px 38px 6px 6px;
301
307
  overflow: hidden;
302
308
  display: grid;
303
309
  gap: 6px;
@@ -383,6 +389,31 @@
383
389
  flex: 1;
384
390
  }
385
391
 
392
+ /* Sprint 42 T4 — drag handle for PTY panel reordering. Only the handle
393
+ is grabbable; the rest of the panel header / xterm body are unaffected. */
394
+ .panel-drag-handle {
395
+ cursor: grab;
396
+ color: var(--tg-text-dim);
397
+ font-size: 12px;
398
+ letter-spacing: -2px;
399
+ user-select: none;
400
+ flex-shrink: 0;
401
+ padding: 0 2px;
402
+ opacity: 0.5;
403
+ transition: opacity 0.15s;
404
+ }
405
+ .panel-drag-handle:hover { opacity: 1; color: var(--tg-accent); }
406
+ .panel-drag-handle:active { cursor: grabbing; }
407
+ .term-panel.dragging {
408
+ opacity: 0.5;
409
+ outline: 2px dashed var(--tg-accent);
410
+ outline-offset: -2px;
411
+ }
412
+ .term-panel.drag-over {
413
+ outline: 2px solid var(--tg-accent);
414
+ outline-offset: -2px;
415
+ }
416
+
386
417
  .status-dot {
387
418
  width: 8px; height: 8px;
388
419
  border-radius: 50%;
@@ -762,6 +793,156 @@
762
793
  filter: none;
763
794
  }
764
795
 
796
+ /* ===== REMOVE PROJECT BUTTON + MODAL (Sprint 42 T4) ===== */
797
+ .prompt-remove-project {
798
+ background: var(--tg-bg);
799
+ border: 1px solid var(--tg-border);
800
+ color: var(--tg-text-dim);
801
+ font-size: 18px;
802
+ line-height: 1;
803
+ padding: 0;
804
+ width: 26px;
805
+ height: 26px;
806
+ border-radius: var(--tg-radius-sm);
807
+ cursor: pointer;
808
+ font-family: var(--tg-sans);
809
+ transition: all 0.15s;
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ margin-left: 4px;
814
+ }
815
+ .prompt-remove-project:hover {
816
+ border-color: var(--tg-red, #f7768e);
817
+ color: var(--tg-red, #f7768e);
818
+ }
819
+ .remove-project-modal {
820
+ display: none;
821
+ position: fixed;
822
+ inset: 0;
823
+ z-index: 3000;
824
+ align-items: center;
825
+ justify-content: center;
826
+ }
827
+ .remove-project-modal.open { display: flex; }
828
+ .remove-project-backdrop {
829
+ position: absolute;
830
+ inset: 0;
831
+ background: rgba(0, 0, 0, 0.72);
832
+ }
833
+ .remove-project-card {
834
+ position: relative;
835
+ background: var(--tg-surface);
836
+ border: 1px solid var(--tg-accent-dim);
837
+ border-radius: 10px;
838
+ padding: 22px 24px 18px;
839
+ width: 460px;
840
+ max-width: calc(100vw - 40px);
841
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
842
+ font-family: var(--tg-sans);
843
+ color: var(--tg-text);
844
+ }
845
+ .remove-project-card h3 {
846
+ margin: 0 0 4px;
847
+ font-size: 16px;
848
+ color: var(--tg-accent);
849
+ }
850
+ .remove-project-card .rpm-help {
851
+ margin: 0 0 14px;
852
+ font-size: 12px;
853
+ color: var(--tg-text-dim);
854
+ line-height: 1.5;
855
+ }
856
+ .remove-project-card .rpm-help code {
857
+ background: var(--tg-bg);
858
+ padding: 1px 5px;
859
+ border-radius: 3px;
860
+ font-family: var(--tg-mono);
861
+ font-size: 11px;
862
+ }
863
+ .remove-project-card .rpm-help strong {
864
+ color: var(--tg-green);
865
+ }
866
+ .remove-project-card label {
867
+ display: block;
868
+ margin-bottom: 10px;
869
+ }
870
+ .remove-project-card label > span {
871
+ display: block;
872
+ font-size: 11px;
873
+ color: var(--tg-text-dim);
874
+ margin-bottom: 3px;
875
+ text-transform: uppercase;
876
+ letter-spacing: 0.5px;
877
+ }
878
+ .remove-project-card select {
879
+ width: 100%;
880
+ background: var(--tg-bg);
881
+ border: 1px solid var(--tg-border);
882
+ color: var(--tg-text);
883
+ font-size: 13px;
884
+ padding: 7px 10px;
885
+ border-radius: var(--tg-radius-sm);
886
+ font-family: var(--tg-mono);
887
+ box-sizing: border-box;
888
+ }
889
+ .remove-project-card select:focus {
890
+ outline: none;
891
+ border-color: var(--tg-accent-dim);
892
+ }
893
+ .remove-project-card .rpm-warning {
894
+ background: rgba(247, 118, 142, 0.08);
895
+ border: 1px solid rgba(247, 118, 142, 0.4);
896
+ color: var(--tg-text);
897
+ font-size: 12px;
898
+ padding: 8px 10px;
899
+ border-radius: 4px;
900
+ margin: 6px 0 8px;
901
+ line-height: 1.5;
902
+ }
903
+ .remove-project-card .rpm-status {
904
+ font-size: 12px;
905
+ min-height: 16px;
906
+ margin: 4px 0 8px;
907
+ color: var(--tg-text-dim);
908
+ }
909
+ .remove-project-card .rpm-status.error { color: var(--tg-red); }
910
+ .remove-project-card .rpm-status.ok { color: var(--tg-green); }
911
+ .remove-project-card .rpm-actions {
912
+ display: flex;
913
+ justify-content: flex-end;
914
+ gap: 8px;
915
+ margin-top: 6px;
916
+ }
917
+ .remove-project-card button {
918
+ font-size: 12px;
919
+ font-weight: 600;
920
+ padding: 6px 16px;
921
+ border-radius: 4px;
922
+ cursor: pointer;
923
+ font-family: var(--tg-sans);
924
+ border: 1px solid var(--tg-border);
925
+ }
926
+ .remove-project-card .rpm-cancel {
927
+ background: transparent;
928
+ color: var(--tg-text-dim);
929
+ }
930
+ .remove-project-card .rpm-cancel:hover {
931
+ color: var(--tg-text);
932
+ border-color: var(--tg-border-active);
933
+ }
934
+ .remove-project-card .rpm-confirm {
935
+ background: var(--tg-red, #f7768e);
936
+ color: var(--tg-bg);
937
+ border-color: var(--tg-red, #f7768e);
938
+ }
939
+ .remove-project-card .rpm-confirm:hover { filter: brightness(1.1); }
940
+ .remove-project-card .rpm-confirm:disabled {
941
+ opacity: 0.5;
942
+ cursor: not-allowed;
943
+ filter: none;
944
+ }
945
+
765
946
  /* ===== Orchestration preview button + modal (Sprint 37 T3) ===== */
766
947
  .prompt-preview-project {
767
948
  background: transparent;
@@ -3439,6 +3620,15 @@
3439
3620
  padding: 24px;
3440
3621
  text-align: center;
3441
3622
  }
3623
+ /* Sprint 41 T3 fixed the JS race but missed the CSS specificity gotcha:
3624
+ `.graph-loading { display: flex }` above wins over the UA default
3625
+ `[hidden] { display: none }` (class > attribute). Result: setting
3626
+ `el.hidden = true` from JS did nothing visually, and "Loading graph…"
3627
+ kept rendering behind "No memories yet". Explicit override below. */
3628
+ .graph-loading[hidden],
3629
+ .graph-empty[hidden] {
3630
+ display: none;
3631
+ }
3442
3632
  .graph-loading-spinner {
3443
3633
  width: 28px;
3444
3634
  height: 28px;