@jhizzard/termdeck 0.10.4 → 0.12.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.
@@ -0,0 +1,217 @@
1
+ /* TermDeck — Knowledge Graph view-controls (Sprint 43 T1)
2
+ *
3
+ * Pure functions for filtering and laying out the D3 force-directed graph.
4
+ * Extracted from graph.js so the filter logic can be unit-tested under
5
+ * `node --test` without a bundler.
6
+ *
7
+ * Dual-export pattern: in Node, `module.exports` returns the API object; in
8
+ * the browser, the same API attaches to `window.GraphControls`. graph.html
9
+ * loads this file with a plain <script> tag BEFORE graph.js so the IIFE in
10
+ * graph.js can read `window.GraphControls.applyControls(...)`.
11
+ *
12
+ * The four user-visible controls are:
13
+ * 1. hide-isolated — drop nodes with degree 0
14
+ * 2. min-degree — keep nodes with degree >= N (N ∈ {0,1,2,3,5})
15
+ * 3. time-window — keep nodes with createdAt within last N days
16
+ * 4. layout — 'force' (default) | 'hierarchical' | 'radial'
17
+ *
18
+ * Filter precedence inside applyControls:
19
+ * time-window ⇒ degree. Time-window first because dropping nodes by age
20
+ * also drops their edges, which changes the degree count for the survivors.
21
+ */
22
+
23
+ (function (root) {
24
+ 'use strict';
25
+
26
+ const WINDOW_DAYS = { '7d': 7, '30d': 30, '90d': 90 };
27
+ const LAYOUTS = ['force', 'hierarchical', 'radial'];
28
+ const VALID_WINDOWS = ['all', '7d', '30d', '90d'];
29
+
30
+ function edgeEndpointId(end) {
31
+ return (end && typeof end === 'object') ? end.id : end;
32
+ }
33
+
34
+ function computeDegrees(nodes, edges) {
35
+ const deg = new Map();
36
+ for (const n of nodes) deg.set(n.id, 0);
37
+ for (const e of edges) {
38
+ const sId = edgeEndpointId(e.source);
39
+ const tId = edgeEndpointId(e.target);
40
+ // Only count edges where BOTH endpoints are in the node set; a half-known
41
+ // edge is "broken" and shouldn't bump the survivor's degree.
42
+ if (!deg.has(sId) || !deg.has(tId)) continue;
43
+ deg.set(sId, deg.get(sId) + 1);
44
+ deg.set(tId, deg.get(tId) + 1);
45
+ }
46
+ return deg;
47
+ }
48
+
49
+ function windowMinTime(windowKey, now) {
50
+ const days = WINDOW_DAYS[windowKey];
51
+ if (!days) return null;
52
+ const ref = (typeof now === 'number' && Number.isFinite(now)) ? now : Date.now();
53
+ return ref - days * 86_400_000;
54
+ }
55
+
56
+ function filterEdgesByNodeSet(edges, idSet) {
57
+ const out = [];
58
+ for (const e of edges) {
59
+ const sId = edgeEndpointId(e.source);
60
+ const tId = edgeEndpointId(e.target);
61
+ if (idSet.has(sId) && idSet.has(tId)) out.push(e);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function applyControls(nodes, edges, controls, now) {
67
+ const c = controls || {};
68
+ let outNodes = Array.isArray(nodes) ? nodes.slice() : [];
69
+ let outEdges = Array.isArray(edges) ? edges.slice() : [];
70
+
71
+ const minTime = windowMinTime(c.window, now);
72
+ if (minTime != null) {
73
+ outNodes = outNodes.filter((n) => {
74
+ if (!n.createdAt) return false;
75
+ const t = Date.parse(n.createdAt);
76
+ return Number.isFinite(t) && t >= minTime;
77
+ });
78
+ outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
79
+ }
80
+
81
+ const minDegreeRaw = Number.isFinite(c.minDegree) ? c.minDegree : 0;
82
+ const minDegree = Math.max(0, Math.floor(minDegreeRaw));
83
+ const hideIsolated = !!c.hideIsolated;
84
+ const threshold = Math.max(minDegree, hideIsolated ? 1 : 0);
85
+
86
+ if (threshold > 0) {
87
+ const deg = computeDegrees(outNodes, outEdges);
88
+ outNodes = outNodes.filter((n) => (deg.get(n.id) || 0) >= threshold);
89
+ outEdges = filterEdgesByNodeSet(outEdges, new Set(outNodes.map((n) => n.id)));
90
+ }
91
+
92
+ return { nodes: outNodes, edges: outEdges };
93
+ }
94
+
95
+ function defaultControls() {
96
+ return { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' };
97
+ }
98
+
99
+ function normalizeControls(c) {
100
+ const def = defaultControls();
101
+ const out = Object.assign({}, def, c || {});
102
+ out.hideIsolated = !!out.hideIsolated;
103
+ const md = Number(out.minDegree);
104
+ out.minDegree = Number.isFinite(md) && md > 0 ? Math.max(0, Math.floor(md)) : 0;
105
+ if (!VALID_WINDOWS.includes(out.window)) out.window = 'all';
106
+ if (!LAYOUTS.includes(out.layout)) out.layout = 'force';
107
+ return out;
108
+ }
109
+
110
+ function encodeControls(qs, controls) {
111
+ const c = normalizeControls(controls);
112
+ if (c.hideIsolated) qs.set('hideIsolated', '1');
113
+ else qs.delete('hideIsolated');
114
+ if (c.minDegree > 0) qs.set('minDegree', String(c.minDegree));
115
+ else qs.delete('minDegree');
116
+ if (c.window !== 'all') qs.set('window', c.window);
117
+ else qs.delete('window');
118
+ if (c.layout !== 'force') qs.set('layout', c.layout);
119
+ else qs.delete('layout');
120
+ return qs;
121
+ }
122
+
123
+ function decodeControls(qs) {
124
+ const out = defaultControls();
125
+ if (qs.get('hideIsolated') === '1') out.hideIsolated = true;
126
+ const md = parseInt(qs.get('minDegree'), 10);
127
+ if (Number.isFinite(md) && md > 0) out.minDegree = md;
128
+ const win = qs.get('window');
129
+ if (win && VALID_WINDOWS.includes(win)) out.window = win;
130
+ const lay = qs.get('layout');
131
+ if (lay && LAYOUTS.includes(lay)) out.layout = lay;
132
+ return out;
133
+ }
134
+
135
+ // Hierarchical layering — assigns each node a "level" by BFS from roots
136
+ // (nodes with no incoming `supersedes` / `caused_by` edges). Used by the
137
+ // hierarchical layout to map level → y position. Falls back to a single
138
+ // level when no directional edges are present.
139
+ function computeHierarchyLevels(nodes, edges) {
140
+ const directional = new Set(['supersedes', 'caused_by', 'blocks']);
141
+ const incoming = new Map();
142
+ const outgoing = new Map();
143
+ for (const n of nodes) {
144
+ incoming.set(n.id, []);
145
+ outgoing.set(n.id, []);
146
+ }
147
+ for (const e of edges) {
148
+ if (!directional.has(e.kind)) continue;
149
+ const sId = edgeEndpointId(e.source);
150
+ const tId = edgeEndpointId(e.target);
151
+ if (incoming.has(tId)) incoming.get(tId).push(sId);
152
+ if (outgoing.has(sId)) outgoing.get(sId).push(tId);
153
+ }
154
+ const levels = new Map();
155
+ const roots = [];
156
+ for (const n of nodes) {
157
+ if ((incoming.get(n.id) || []).length === 0) {
158
+ roots.push(n.id);
159
+ levels.set(n.id, 0);
160
+ }
161
+ }
162
+ if (roots.length === 0) {
163
+ for (const n of nodes) levels.set(n.id, 0);
164
+ return levels;
165
+ }
166
+ const queue = roots.slice();
167
+ while (queue.length) {
168
+ const id = queue.shift();
169
+ const cur = levels.get(id) || 0;
170
+ for (const next of (outgoing.get(id) || [])) {
171
+ const prev = levels.get(next);
172
+ const candidate = cur + 1;
173
+ if (prev == null || candidate > prev) {
174
+ levels.set(next, candidate);
175
+ queue.push(next);
176
+ }
177
+ }
178
+ }
179
+ for (const n of nodes) {
180
+ if (!levels.has(n.id)) levels.set(n.id, 0);
181
+ }
182
+ return levels;
183
+ }
184
+
185
+ // Radial radius helper — high degree → small radius (center). Returns a
186
+ // function that takes a node and returns a target radius (0 < r ≤ rMax).
187
+ function radialRadiusFn(degreesMap, rMax) {
188
+ let maxDeg = 0;
189
+ for (const v of degreesMap.values()) if (v > maxDeg) maxDeg = v;
190
+ if (maxDeg === 0) return () => rMax * 0.5;
191
+ return (n) => {
192
+ const deg = degreesMap.get(n.id) || 0;
193
+ const t = 1 - (deg / maxDeg);
194
+ return rMax * (0.15 + 0.75 * t);
195
+ };
196
+ }
197
+
198
+ const api = {
199
+ LAYOUTS,
200
+ VALID_WINDOWS,
201
+ computeDegrees,
202
+ windowMinTime,
203
+ applyControls,
204
+ defaultControls,
205
+ normalizeControls,
206
+ encodeControls,
207
+ decodeControls,
208
+ computeHierarchyLevels,
209
+ radialRadiusFn,
210
+ };
211
+
212
+ if (typeof module !== 'undefined' && module.exports) {
213
+ module.exports = api;
214
+ } else {
215
+ root.GraphControls = api;
216
+ }
217
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -42,6 +42,41 @@
42
42
 
43
43
  <div class="graph-filters" id="graphFilters" role="toolbar" aria-label="Edge type filters"></div>
44
44
 
45
+ <div class="graph-filters graph-filters-row2" id="graphControls" role="toolbar" aria-label="Graph view controls">
46
+ <label class="graph-control graph-control-toggle" title="Drop nodes with zero edges">
47
+ <input type="checkbox" id="ctlHideIsolated">
48
+ <span>hide isolated</span>
49
+ </label>
50
+ <label class="graph-control" title="Keep nodes with at least N edges">
51
+ <span>min degree</span>
52
+ <select id="ctlMinDegree">
53
+ <option value="0">all</option>
54
+ <option value="1">≥ 1</option>
55
+ <option value="2">≥ 2</option>
56
+ <option value="3">≥ 3</option>
57
+ <option value="5">≥ 5</option>
58
+ </select>
59
+ </label>
60
+ <label class="graph-control" title="Keep nodes created within window">
61
+ <span>window</span>
62
+ <select id="ctlWindow">
63
+ <option value="all">all time</option>
64
+ <option value="7d">last 7 days</option>
65
+ <option value="30d">last 30 days</option>
66
+ <option value="90d">last 90 days</option>
67
+ </select>
68
+ </label>
69
+ <label class="graph-control" title="Force algorithm used to position nodes">
70
+ <span>layout</span>
71
+ <select id="ctlLayout">
72
+ <option value="force">force-directed</option>
73
+ <option value="hierarchical">hierarchical</option>
74
+ <option value="radial">radial</option>
75
+ </select>
76
+ </label>
77
+ <span class="graph-control-stat" id="ctlVisibleStat" aria-live="polite"></span>
78
+ </div>
79
+
45
80
  <main class="graph-stage" id="graphStage">
46
81
  <div class="graph-loading" id="graphLoading">
47
82
  <div class="graph-loading-spinner" aria-hidden="true"></div>
@@ -100,6 +135,7 @@
100
135
  </main>
101
136
 
102
137
  <script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
138
+ <script src="graph-controls.js"></script>
103
139
  <script src="graph.js" defer></script>
104
140
  </body>
105
141
  </html>
@@ -76,14 +76,19 @@
76
76
 
77
77
  // -------- State ----------------------------------------------------------
78
78
 
79
+ const GC = (typeof window !== 'undefined' && window.GraphControls) || null;
80
+
79
81
  const state = {
80
82
  mode: 'project', // 'project' | 'memory'
81
83
  project: null,
82
84
  memoryId: null,
83
85
  depth: 2,
84
- nodes: [],
85
- edges: [],
86
+ nodes: [], // raw nodes from /api/graph/* — unfiltered
87
+ edges: [], // raw edges from /api/graph/* — unfiltered
88
+ visibleNodes: [], // post-filter, what the simulation sees
89
+ visibleEdges: [],
86
90
  activeKinds: new Set(Object.keys(EDGE_COLORS)),
91
+ controls: GC ? GC.defaultControls() : { hideIsolated: false, minDegree: 0, window: 'all', layout: 'force' },
87
92
  selectedNodeId: null,
88
93
  hoverNodeId: null,
89
94
  searchTerm: '',
@@ -172,6 +177,7 @@
172
177
  state.mode = 'project';
173
178
  state.project = project;
174
179
  }
180
+ if (GC) state.controls = GC.decodeControls(qs);
175
181
  }
176
182
 
177
183
  function writeUrlState() {
@@ -182,6 +188,7 @@
182
188
  } else if (state.project) {
183
189
  qs.set('project', state.project);
184
190
  }
191
+ if (GC) GC.encodeControls(qs, state.controls);
185
192
  const url = qs.toString() ? `?${qs.toString()}` : window.location.pathname;
186
193
  window.history.replaceState(null, '', url);
187
194
  }
@@ -259,6 +266,8 @@
259
266
  // node render all paint over each other after a mode/project switch.
260
267
  hideEmpty();
261
268
  clearGraphSvg();
269
+ state.rawNodes = [];
270
+ state.rawEdges = [];
262
271
  state.nodes = [];
263
272
  state.edges = [];
264
273
  setLoading('Loading graph…');
@@ -267,18 +276,18 @@
267
276
  if (state.mode === 'project' && state.project === '__all__') {
268
277
  data = await api('/api/graph/all');
269
278
  if (data.enabled === false) return showDisabled(data);
270
- state.nodes = data.nodes || [];
271
- state.edges = data.edges || [];
279
+ state.rawNodes = data.nodes || [];
280
+ state.rawEdges = data.edges || [];
272
281
  if (data.truncated) {
273
282
  showToast(
274
- `Showing ${state.nodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
283
+ `Showing ${state.rawNodes.length} most-recent of ${data.totalAvailable} memories — narrow by project to see specific clusters.`,
275
284
  );
276
285
  }
277
286
  } else if (state.mode === 'memory') {
278
287
  data = await api(`/api/graph/memory/${encodeURIComponent(state.memoryId)}?depth=${state.depth}`);
279
288
  if (data.enabled === false) return showDisabled(data);
280
- state.nodes = data.nodes || [];
281
- state.edges = data.edges || [];
289
+ state.rawNodes = data.nodes || [];
290
+ state.rawEdges = data.edges || [];
282
291
  // Use the root memory's project as the view's "current project" for
283
292
  // the legend / drawer / fallback color when nodes span projects.
284
293
  if (data.root && data.root.project) state.project = data.root.project;
@@ -287,19 +296,17 @@
287
296
  state.project = name;
288
297
  data = await api(`/api/graph/project/${encodeURIComponent(name)}`);
289
298
  if (data.enabled === false) return showDisabled(data);
290
- state.nodes = data.nodes || [];
291
- state.edges = data.edges || [];
299
+ state.rawNodes = data.nodes || [];
300
+ state.rawEdges = data.edges || [];
292
301
  }
293
302
  writeUrlState();
294
303
  hideLoading();
295
- if (state.nodes.length === 0) {
304
+ if (state.rawNodes.length === 0) {
296
305
  showEmpty();
297
306
  return;
298
307
  }
299
308
  hideEmpty();
300
- renderFilters();
301
- renderGraph();
302
- updateStats();
309
+ applyControlsAndRender();
303
310
  } catch (err) {
304
311
  // Even on error, drop the empty-state overlay so the failure message
305
312
  // shows alone instead of stacking on top of "No memories yet".
@@ -308,6 +315,35 @@
308
315
  }
309
316
  }
310
317
 
318
+ // Sprint 43 T1 — re-derive state.nodes/state.edges from the raw API result
319
+ // and the four user controls, then drive the existing render pipeline.
320
+ // Called after every fetch and after any control change.
321
+ function applyControlsAndRender() {
322
+ const raw = { nodes: state.rawNodes || [], edges: state.rawEdges || [] };
323
+ let visible;
324
+ if (GC) {
325
+ visible = GC.applyControls(raw.nodes, raw.edges, state.controls);
326
+ } else {
327
+ visible = { nodes: raw.nodes.slice(), edges: raw.edges.slice() };
328
+ }
329
+ state.nodes = visible.nodes;
330
+ state.edges = visible.edges;
331
+ renderFilters();
332
+ renderGraph();
333
+ updateStats();
334
+ updateControlStat(raw.nodes.length, visible.nodes.length);
335
+ }
336
+
337
+ function updateControlStat(total, visible) {
338
+ const el = document.getElementById('ctlVisibleStat');
339
+ if (!el) return;
340
+ if (total === visible) {
341
+ el.textContent = '';
342
+ } else {
343
+ el.textContent = `${visible} of ${total} visible`;
344
+ }
345
+ }
346
+
311
347
  // -------- Render --------------------------------------------------------
312
348
 
313
349
  function setLoading(msg) {
@@ -417,6 +453,48 @@
417
453
  svg().setAttribute('height', state.height);
418
454
  }
419
455
 
456
+ // Sprint 43 T1 — apply layout-specific forces to the simulation.
457
+ // force — forceCenter at midpoint (default).
458
+ // hierarchical — forceY from BFS levels over directional edges (supersedes /
459
+ // caused_by / blocks). Roots float top, leaves drift down.
460
+ // radial — forceRadial centered at midpoint, radius from inverse
461
+ // degree (high degree → near center).
462
+ function applyLayoutForces(sim, nodes, links) {
463
+ const cx = state.width / 2;
464
+ const cy = state.height / 2;
465
+ const layout = (state.controls && state.controls.layout) || 'force';
466
+
467
+ sim.force('center', null);
468
+ sim.force('hier-y', null);
469
+ sim.force('hier-x', null);
470
+ sim.force('radial', null);
471
+
472
+ if (layout === 'hierarchical' && GC) {
473
+ const levels = GC.computeHierarchyLevels(state.nodes, state.edges);
474
+ let maxLevel = 0;
475
+ for (const v of levels.values()) if (v > maxLevel) maxLevel = v;
476
+ const denom = Math.max(1, maxLevel);
477
+ const top = state.height * 0.10;
478
+ const bottom = state.height * 0.90;
479
+ sim.force('hier-y', window.d3.forceY((d) => {
480
+ const lvl = levels.get(d.id) || 0;
481
+ return top + (lvl / denom) * (bottom - top);
482
+ }).strength(0.65));
483
+ sim.force('hier-x', window.d3.forceX(cx).strength(0.04));
484
+ return;
485
+ }
486
+
487
+ if (layout === 'radial' && GC) {
488
+ const deg = GC.computeDegrees(state.nodes, state.edges);
489
+ const rMax = Math.min(state.width, state.height) * 0.42;
490
+ const radiusFn = GC.radialRadiusFn(deg, rMax);
491
+ sim.force('radial', window.d3.forceRadial(radiusFn, cx, cy).strength(0.55));
492
+ return;
493
+ }
494
+
495
+ sim.force('center', window.d3.forceCenter(cx, cy));
496
+ }
497
+
420
498
  function renderGraph() {
421
499
  if (!window.d3) {
422
500
  setLoading('D3.js failed to load (CDN blocked?)');
@@ -441,10 +519,11 @@
441
519
  const sim = window.d3.forceSimulation(nodes)
442
520
  .force('link', window.d3.forceLink(links).id((d) => d.id).distance((l) => 60 + (1 - (l.weight ?? 0.5)) * 40))
443
521
  .force('charge', window.d3.forceManyBody().strength(-260))
444
- .force('center', window.d3.forceCenter(state.width / 2, state.height / 2))
445
522
  .force('collide', window.d3.forceCollide().radius((d) => nodeRadius(d) + 4))
446
523
  .alphaDecay(0.02);
447
524
 
525
+ applyLayoutForces(sim, nodes, links);
526
+
448
527
  state.sim = sim;
449
528
 
450
529
  const rootSel = window.d3.select(root());
@@ -742,6 +821,41 @@
742
821
  });
743
822
  $('graphFit').addEventListener('click', () => fitToView());
744
823
 
824
+ // Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
825
+ // wire change handlers that mutate state.controls + URL + re-render from
826
+ // the cached raw fetch so toggling is fast (no API round-trip).
827
+ const ctlHide = $('ctlHideIsolated');
828
+ const ctlMin = $('ctlMinDegree');
829
+ const ctlWin = $('ctlWindow');
830
+ const ctlLay = $('ctlLayout');
831
+ if (ctlHide) ctlHide.checked = !!state.controls.hideIsolated;
832
+ if (ctlMin) ctlMin.value = String(state.controls.minDegree || 0);
833
+ if (ctlWin) ctlWin.value = state.controls.window || 'all';
834
+ if (ctlLay) ctlLay.value = state.controls.layout || 'force';
835
+
836
+ function onControlChange() {
837
+ state.controls = (GC ? GC.normalizeControls(state.controls) : state.controls);
838
+ writeUrlState();
839
+ if ((state.rawNodes || []).length === 0) return;
840
+ applyControlsAndRender();
841
+ }
842
+ if (ctlHide) ctlHide.addEventListener('change', () => {
843
+ state.controls.hideIsolated = !!ctlHide.checked;
844
+ onControlChange();
845
+ });
846
+ if (ctlMin) ctlMin.addEventListener('change', () => {
847
+ state.controls.minDegree = parseInt(ctlMin.value, 10) || 0;
848
+ onControlChange();
849
+ });
850
+ if (ctlWin) ctlWin.addEventListener('change', () => {
851
+ state.controls.window = ctlWin.value;
852
+ onControlChange();
853
+ });
854
+ if (ctlLay) ctlLay.addEventListener('change', () => {
855
+ state.controls.layout = ctlLay.value;
856
+ onControlChange();
857
+ });
858
+
745
859
  $('gdClose').addEventListener('click', closeDrawer);
746
860
  $('gdExpand').addEventListener('click', () => {
747
861
  if (!state.selectedNodeId) return;
@@ -763,7 +877,9 @@
763
877
  window.addEventListener('resize', () => {
764
878
  sizeStage();
765
879
  if (state.sim) {
766
- state.sim.force('center', window.d3.forceCenter(state.width / 2, state.height / 2));
880
+ // Re-apply layout-specific forces with the new dimensions; this
881
+ // handles all three layouts (force / hierarchical / radial).
882
+ applyLayoutForces(state.sim, null, null);
767
883
  state.sim.alpha(0.3).restart();
768
884
  }
769
885
  });
@@ -62,6 +62,7 @@
62
62
  <button id="btn-config">config</button>
63
63
  <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
64
64
  <button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
65
+ <button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
65
66
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
66
67
  <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://termdeck-docs.vercel.app','_blank','noopener')">help</button>
67
68
  </div>
@@ -135,6 +136,7 @@
135
136
  <option value="">no project</option>
136
137
  </select>
137
138
  <button class="prompt-add-project" id="btnAddProject" title="Add a new project">+</button>
139
+ <button class="prompt-remove-project" id="btnRemoveProject" title="Remove a project (files on disk are untouched)">−</button>
138
140
  <button class="prompt-preview-project" id="btnPreviewProject" title="Preview orchestration scaffolding for the selected project" disabled>preview</button>
139
141
  <button class="prompt-launch" id="promptLaunch">launch</button>
140
142
  </div>
@@ -169,6 +171,29 @@
169
171
  </div>
170
172
  </div>
171
173
 
174
+ <!-- Remove-project modal (Sprint 42 T4). Hidden by default. -->
175
+ <div class="remove-project-modal" id="removeProjectModal" role="dialog" aria-modal="true" aria-labelledby="rpmTitle">
176
+ <div class="remove-project-backdrop"></div>
177
+ <div class="remove-project-card">
178
+ <h3 id="rpmTitle">Remove a project</h3>
179
+ <p class="rpm-help">
180
+ This unregisters the project from <code>~/.termdeck/config.yaml</code>.
181
+ <strong>Files on disk are untouched</strong> — your source code, git history,
182
+ and everything at the project's path stay exactly where they are.
183
+ </p>
184
+ <label>
185
+ <span>Project to remove</span>
186
+ <select id="rpmSelect"><option value="">— pick a project —</option></select>
187
+ </label>
188
+ <div class="rpm-warning" id="rpmWarning" hidden></div>
189
+ <div class="rpm-status" id="rpmStatus"></div>
190
+ <div class="rpm-actions">
191
+ <button class="rpm-cancel" id="rpmCancel">cancel</button>
192
+ <button class="rpm-confirm" id="rpmConfirm" disabled>remove project</button>
193
+ </div>
194
+ </div>
195
+ </div>
196
+
172
197
  <!-- Orchestration-preview modal (Sprint 37 T3). Hidden by default. -->
173
198
  <div class="preview-project-modal" id="previewProjectModal" role="dialog" aria-modal="true" aria-labelledby="ppmTitle">
174
199
  <div class="preview-project-backdrop"></div>