kanbango 3.1.0 → 3.2.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/index.html CHANGED
@@ -157,16 +157,28 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
157
157
  display: flex;
158
158
  align-items: center;
159
159
  gap: 8px;
160
- padding: 7px 12px;
160
+ padding: 8px 12px;
161
161
  background: #f8fafc;
162
162
  border-bottom: 1px solid var(--border);
163
- cursor: pointer;
164
163
  user-select: none;
165
164
  }
166
- .sl-header:hover { background: #f1f5f9; }
167
- .sl-arrow { font-size: 10px; color: var(--muted); transition: transform 0.15s; }
168
- .sl-header.collapsed .sl-arrow { transform: rotate(-90deg); }
169
- .sl-name { font-size: 12px; font-weight: 600; color: var(--text); }
165
+ .sl-header-main {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ flex: 1;
170
+ min-width: 0;
171
+ }
172
+ .sl-name { font-size: 13px; font-weight: 700; color: var(--text); }
173
+ .sl-id {
174
+ font-size: 10px;
175
+ font-weight: 700;
176
+ color: var(--muted);
177
+ background: #e2e8f0;
178
+ border-radius: 4px;
179
+ padding: 1px 6px;
180
+ letter-spacing: 0.3px;
181
+ }
170
182
  .sl-cnt {
171
183
  font-size: 11px;
172
184
  color: var(--muted);
@@ -175,10 +187,232 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
175
187
  padding: 1px 6px;
176
188
  font-weight: 600;
177
189
  }
190
+ .sl-status {
191
+ font-size: 10px;
192
+ font-weight: 700;
193
+ text-transform: uppercase;
194
+ letter-spacing: 0.4px;
195
+ border-radius: 999px;
196
+ padding: 2px 8px;
197
+ }
198
+ .sl-status.empty { background: #f1f5f9; color: #64748b; }
199
+ .sl-status.planned { background: #dbeafe; color: #1d4ed8; }
200
+ .sl-status.active { background: #ffedd5; color: #c2410c; }
201
+ .sl-status.done { background: #dcfce7; color: #15803d; }
202
+ .sl-status.archived { background: #f1f5f9; color: #64748b; }
178
203
  .sl-progress {
204
+ font-size: 11px;
205
+ color: var(--muted);
206
+ white-space: nowrap;
207
+ }
208
+ .sl-toggles {
209
+ display: flex;
210
+ align-items: center;
211
+ gap: 4px;
179
212
  margin-left: auto;
213
+ flex-shrink: 0;
214
+ }
215
+ .sl-toggle {
216
+ border: 1px solid var(--border);
217
+ background: #fff;
218
+ color: var(--muted);
219
+ border-radius: 6px;
180
220
  font-size: 11px;
221
+ font-weight: 600;
222
+ padding: 3px 9px;
223
+ cursor: pointer;
224
+ font-family: inherit;
225
+ display: inline-flex;
226
+ align-items: center;
227
+ gap: 4px;
228
+ }
229
+ .sl-toggle:hover { background: #f1f5f9; color: var(--text); }
230
+ .sl-toggle.on {
231
+ background: #e0e7ff;
232
+ border-color: #c7d2fe;
233
+ color: #3730a3;
234
+ }
235
+ .sl-toggle .chev { font-size: 9px; transition: transform 0.15s; display: inline-block; }
236
+ .sl-toggle.on .chev { transform: rotate(90deg); }
237
+ .sl-edit-btn {
238
+ border: none;
239
+ background: #e2e8f0;
181
240
  color: var(--muted);
241
+ border-radius: 6px;
242
+ font-size: 10px;
243
+ font-weight: 700;
244
+ padding: 4px 9px;
245
+ cursor: pointer;
246
+ font-family: inherit;
247
+ }
248
+ .sl-edit-btn:hover { background: #cbd5e1; color: var(--text); }
249
+ .sl-edit-btn.active { background: #c7d2fe; color: #3730a3; }
250
+
251
+ /* ── Epic detail panel (swimlane context) ── */
252
+ .sl-epic-shell {
253
+ border-bottom: 1px solid var(--border);
254
+ background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
255
+ }
256
+ .sl-epic-detail {
257
+ padding: 12px 14px 14px;
258
+ display: flex;
259
+ flex-direction: column;
260
+ gap: 10px;
261
+ font-size: 12px;
262
+ }
263
+ .sl-epic-hero {
264
+ display: flex;
265
+ flex-wrap: wrap;
266
+ gap: 8px 12px;
267
+ align-items: flex-start;
268
+ justify-content: space-between;
269
+ }
270
+ .sl-epic-goals {
271
+ flex: 1;
272
+ min-width: 220px;
273
+ background: #fff;
274
+ border: 1px solid #e2e8f0;
275
+ border-left: 3px solid #6366f1;
276
+ border-radius: 8px;
277
+ padding: 10px 12px;
278
+ }
279
+ .sl-epic-goals .sl-epic-label { color: #6366f1; }
280
+ .sl-epic-goals .sl-epic-text {
281
+ font-size: 13px;
282
+ font-weight: 600;
283
+ line-height: 1.45;
284
+ color: #1e293b;
285
+ }
286
+ .sl-epic-rollup {
287
+ display: flex;
288
+ flex-wrap: wrap;
289
+ gap: 6px;
290
+ align-content: flex-start;
291
+ }
292
+ .sl-chip {
293
+ display: inline-flex;
294
+ align-items: center;
295
+ gap: 4px;
296
+ background: #fff;
297
+ border: 1px solid #e2e8f0;
298
+ border-radius: 999px;
299
+ padding: 3px 10px;
300
+ font-size: 11px;
301
+ font-weight: 600;
302
+ color: #475569;
303
+ }
304
+ .sl-chip strong { color: #0f172a; font-weight: 700; }
305
+ .sl-chip.done { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
306
+ .sl-chip.active { border-color: #fed7aa; background: #fff7ed; color: #c2410c; }
307
+ .sl-chip.planned { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
308
+ .sl-chip.icebox { border-color: #e2e8f0; background: #f8fafc; color: #64748b; }
309
+
310
+ .sl-epic-body {
311
+ display: grid;
312
+ grid-template-columns: 1.4fr 1fr;
313
+ gap: 10px;
314
+ }
315
+ @media (max-width: 900px) {
316
+ .sl-epic-body { grid-template-columns: 1fr; }
317
+ }
318
+ .sl-epic-card {
319
+ background: #fff;
320
+ border: 1px solid #e2e8f0;
321
+ border-radius: 8px;
322
+ padding: 10px 12px;
323
+ min-width: 0;
324
+ }
325
+ .sl-epic-card.scope-pair {
326
+ display: grid;
327
+ grid-template-columns: 1fr 1fr;
328
+ gap: 10px 14px;
329
+ }
330
+ .sl-epic-label {
331
+ font-size: 10px;
332
+ font-weight: 700;
333
+ text-transform: uppercase;
334
+ letter-spacing: 0.55px;
335
+ color: var(--muted);
336
+ margin-bottom: 6px;
337
+ }
338
+ .sl-epic-text {
339
+ color: #334155;
340
+ white-space: pre-wrap;
341
+ line-height: 1.5;
342
+ word-break: break-word;
343
+ }
344
+ .sl-epic-text.empty { color: #94a3b8; font-style: italic; }
345
+ .sl-epic-list {
346
+ margin: 0;
347
+ padding: 0;
348
+ list-style: none;
349
+ display: flex;
350
+ flex-direction: column;
351
+ gap: 4px;
352
+ }
353
+ .sl-epic-list li {
354
+ position: relative;
355
+ padding: 4px 8px 4px 22px;
356
+ background: #f8fafc;
357
+ border-radius: 6px;
358
+ line-height: 1.4;
359
+ color: #334155;
360
+ }
361
+ .sl-epic-list li::before {
362
+ content: "";
363
+ position: absolute;
364
+ left: 8px;
365
+ top: 10px;
366
+ width: 6px;
367
+ height: 6px;
368
+ border-radius: 50%;
369
+ background: #6366f1;
370
+ }
371
+ .sl-epic-list.out li {
372
+ background: #f8fafc;
373
+ color: #94a3b8;
374
+ }
375
+ .sl-epic-list.out li::before { background: #cbd5e1; }
376
+ .sl-epic-notes {
377
+ border-style: dashed;
378
+ color: #64748b;
379
+ }
380
+
381
+ .sl-epic-edit-grid {
382
+ display: grid;
383
+ grid-template-columns: 1fr 1fr;
384
+ gap: 8px 12px;
385
+ padding: 12px 14px 14px;
386
+ }
387
+ .sl-epic-edit-grid .span2 { grid-column: 1 / -1; }
388
+ .sl-epic-edit-grid label {
389
+ display: block;
390
+ font-size: 10px;
391
+ font-weight: 700;
392
+ text-transform: uppercase;
393
+ letter-spacing: 0.5px;
394
+ color: var(--muted);
395
+ margin-bottom: 3px;
396
+ }
397
+ .sl-epic-edit-grid input,
398
+ .sl-epic-edit-grid textarea {
399
+ width: 100%;
400
+ border: 1px solid var(--border);
401
+ border-radius: var(--radius);
402
+ padding: 5px 8px;
403
+ font-size: 12px;
404
+ font-family: inherit;
405
+ color: var(--text);
406
+ box-sizing: border-box;
407
+ resize: vertical;
408
+ background: #fff;
409
+ }
410
+ .sl-epic-edit-grid textarea { min-height: 52px; }
411
+ .sl-epic-edit-actions {
412
+ grid-column: 1 / -1;
413
+ display: flex;
414
+ gap: 8px;
415
+ justify-content: flex-end;
182
416
  }
183
417
 
184
418
  /* ── Swimlane body grid ── */
@@ -487,9 +721,30 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
487
721
  <h1>Donna Kanban</h1>
488
722
  <span class="hstats" id="hstats">—</span>
489
723
  <span class="hspacer"></span>
724
+ <button class="btn-new" id="btn-show-archived" onclick="toggleShowArchived()" style="background:#94a3b8">Show archived</button>
725
+ <button class="btn-new" id="btn-new-epic" onclick="toggleNewEpicForm()" style="background:#64748b">+ New epic</button>
490
726
  <button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ New task</button>
491
727
  </header>
492
728
 
729
+ <div class="new-epic-strip" id="new-epic-strip">
730
+ <div class="ne-field">
731
+ <div class="ne-label">Epic title</div>
732
+ <input class="ne-input ne-title" id="nx-title" placeholder="Initiative name…" autocomplete="off">
733
+ </div>
734
+ <div class="ne-field" style="flex:1;min-width:200px">
735
+ <div class="ne-label">Description</div>
736
+ <input class="ne-input" id="nx-description" placeholder="Context for agents…" autocomplete="off" style="width:100%">
737
+ </div>
738
+ <div class="ne-field" style="flex:1;min-width:160px">
739
+ <div class="ne-label">Goals</div>
740
+ <input class="ne-input" id="nx-goals" placeholder="Outcome…" autocomplete="off" style="width:100%">
741
+ </div>
742
+ <div class="ne-buttons">
743
+ <button class="btn-sm btn-confirm" onclick="submitNewEpic()">Add epic</button>
744
+ <button class="btn-sm btn-cancel" onclick="toggleNewEpicForm()">Cancel</button>
745
+ </div>
746
+ </div>
747
+
493
748
  <div class="new-epic-strip" id="new-strip">
494
749
  <div class="ne-field">
495
750
  <div class="ne-label">Title</div>
@@ -506,7 +761,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
506
761
  </div>
507
762
  <div class="ne-field">
508
763
  <div class="ne-label">Epic</div>
509
- <input class="ne-input ne-group" id="ne-group" placeholder="Epic group (optional)…" autocomplete="off">
764
+ <input class="ne-input ne-group" id="ne-group" list="epic-datalist" placeholder="E001 or title…" autocomplete="off">
765
+ <datalist id="epic-datalist"></datalist>
510
766
  </div>
511
767
  <div class="ne-buttons">
512
768
  <button class="btn-sm btn-confirm" onclick="submitNew()">Add</button>
@@ -532,23 +788,70 @@ const COL_DEFS = [
532
788
  let allTasks = [];
533
789
  let allEpicEntities = [];
534
790
  let colCounts = {};
535
- let collapsed = new Set();
791
+ // Separate collapse: epic info panel vs task board (default: info open, tasks open)
792
+ let collapsedInfo = new Set();
793
+ let collapsedTasks = new Set();
536
794
  let expanded = new Set();
537
795
  let editingId = null;
538
796
  let dirtyEdits = {}; // taskId -> editable task draft
797
+ let editingEpicId = null;
798
+ let dirtyEpicEdits = {}; // epicId -> draft
799
+ let showArchived = false;
539
800
 
540
801
  // ── Load & render ─────────────────────────────────────────────────────────────
541
802
 
542
803
  async function load() {
804
+ const q = showArchived ? "?include_archived=true" : "";
543
805
  const [boardRes, epicsRes] = await Promise.all([
544
- fetch("/api/board"),
545
- fetch("/api/epics")
806
+ fetch("/api/board" + q),
807
+ fetch("/api/epics" + q)
546
808
  ]);
547
809
  allTasks = await boardRes.json();
548
810
  allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
811
+ refreshEpicDatalist();
549
812
  render();
550
813
  }
551
814
 
815
+ async function archiveEpic(epicId, archived) {
816
+ const action = archived ? "archive" : "unarchive";
817
+ await fetch(`/api/epic-entities/${enc(epicId)}/${action}`, { method: "POST" });
818
+ if (editingEpicId === epicId) editingEpicId = null;
819
+ await load();
820
+ }
821
+
822
+ async function deleteEpicEntity(epicId, title) {
823
+ if (!confirm(`Delete epic ${epicId} "${title}" and ALL its tasks? This cannot be undone.`)) return;
824
+ await fetch(`/api/epic-entities/${enc(epicId)}`, { method: "DELETE" });
825
+ if (editingEpicId === epicId) editingEpicId = null;
826
+ await load();
827
+ }
828
+
829
+ async function deleteTaskCard(taskId, title) {
830
+ if (!confirm(`Delete task ${taskId}: ${title}?`)) return;
831
+ await fetch(`/api/tasks/${enc(taskId)}`, { method: "DELETE" });
832
+ expanded.delete(taskId);
833
+ editingId = null;
834
+ await load();
835
+ }
836
+
837
+ function toggleShowArchived() {
838
+ showArchived = !showArchived;
839
+ const btn = document.getElementById("btn-show-archived");
840
+ if (btn) {
841
+ btn.textContent = showArchived ? "Hide archived" : "Show archived";
842
+ btn.style.background = showArchived ? "#6366f1" : "#94a3b8";
843
+ }
844
+ load();
845
+ }
846
+
847
+ function refreshEpicDatalist() {
848
+ const list = document.getElementById("epic-datalist");
849
+ if (!list) return;
850
+ list.innerHTML = allEpicEntities.map(e =>
851
+ `<option value="${escHtml(e.id)}">${escHtml(e.title)}</option>`
852
+ ).join("");
853
+ }
854
+
552
855
  function render() {
553
856
  colCounts = {};
554
857
  COL_DEFS.forEach(c => (colCounts[c.id] = 0));
@@ -557,7 +860,8 @@ function render() {
557
860
  // Header stats
558
861
  const total = allTasks.length;
559
862
  const done = allTasks.filter(e => e.column === "done").length;
560
- document.getElementById("hstats").textContent = `${done}/${total} done`;
863
+ const epicN = allEpicEntities.length;
864
+ document.getElementById("hstats").textContent = `${done}/${total} done · ${epicN} epics`;
561
865
 
562
866
  // Col headers
563
867
  const hdr = document.getElementById("col-headers");
@@ -582,10 +886,15 @@ function groupTasks(tasks) {
582
886
  if (!map.has(key)) map.set(key, []);
583
887
  map.get(key).push(t);
584
888
  });
889
+ // Include epics that have no tasks yet
890
+ allEpicEntities.forEach(e => {
891
+ if (!map.has(e.id)) map.set(e.id, []);
892
+ });
893
+
585
894
  return [...map.entries()]
586
895
  .sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
587
896
  .map(([key, laneTasks]) => {
588
- const entity = allEpicEntities.find(e => e.id === key);
897
+ const entity = allEpicEntities.find(e => e.id === key) || null;
589
898
  const name = key === "—"
590
899
  ? "—"
591
900
  : (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
@@ -593,56 +902,304 @@ function groupTasks(tasks) {
593
902
  key,
594
903
  name,
595
904
  epicId: key === "—" ? null : key,
596
- status: entity ? entity.status : null,
597
- description: entity ? entity.description : "",
598
- goals: entity ? entity.goals : "",
905
+ entity,
599
906
  tasks: laneTasks
600
907
  };
601
908
  });
602
909
  }
603
910
 
911
+ function epicListHtml(items, emptyLabel, extraClass) {
912
+ if (!items || items.length === 0) {
913
+ return `<div class="sl-epic-text empty">${escHtml(emptyLabel)}</div>`;
914
+ }
915
+ return `<ul class="sl-epic-list${extraClass ? " " + extraClass : ""}">${
916
+ items.map(i => `<li>${escHtml(i)}</li>`).join("")
917
+ }</ul>`;
918
+ }
919
+
920
+ function renderEpicDetailView(entity) {
921
+ const shell = el("div", "sl-epic-shell");
922
+ const panel = el("div", "sl-epic-detail");
923
+ const p = entity.progress || {};
924
+
925
+ const hero = el("div", "sl-epic-hero");
926
+ const goals = el("div", "sl-epic-goals");
927
+ goals.innerHTML = `<div class="sl-epic-label">Goals</div>${
928
+ entity.goals
929
+ ? `<div class="sl-epic-text">${escHtml(entity.goals)}</div>`
930
+ : `<div class="sl-epic-text empty">No goals set</div>`
931
+ }`;
932
+ hero.appendChild(goals);
933
+
934
+ const rollup = el("div", "sl-epic-rollup");
935
+ rollup.innerHTML = `
936
+ <span class="sl-chip"><strong>${p.tasks_total || 0}</strong> total</span>
937
+ <span class="sl-chip active"><strong>${p.tasks_active || 0}</strong> active</span>
938
+ <span class="sl-chip planned"><strong>${p.tasks_planned || 0}</strong> planned</span>
939
+ <span class="sl-chip icebox"><strong>${p.tasks_icebox || 0}</strong> icebox</span>
940
+ <span class="sl-chip done"><strong>${p.tasks_done || 0}</strong> done</span>
941
+ `;
942
+ hero.appendChild(rollup);
943
+ panel.appendChild(hero);
944
+
945
+ const body = el("div", "sl-epic-body");
946
+
947
+ const descCard = el("div", "sl-epic-card");
948
+ descCard.innerHTML = `<div class="sl-epic-label">Description</div>${
949
+ entity.description
950
+ ? `<div class="sl-epic-text">${escHtml(entity.description)}</div>`
951
+ : `<div class="sl-epic-text empty">No description yet — click Edit epic to add context for agents.</div>`
952
+ }`;
953
+ body.appendChild(descCard);
954
+
955
+ const scopeCard = el("div", "sl-epic-card scope-pair");
956
+ scopeCard.innerHTML = `
957
+ <div>
958
+ <div class="sl-epic-label">In scope</div>
959
+ ${epicListHtml(entity.in_scope, "Nothing listed", "")}
960
+ </div>
961
+ <div>
962
+ <div class="sl-epic-label">Out of scope</div>
963
+ ${epicListHtml(entity.out_of_scope, "Nothing listed", "out")}
964
+ </div>
965
+ `;
966
+ body.appendChild(scopeCard);
967
+ panel.appendChild(body);
968
+
969
+ if (entity.notes) {
970
+ const notes = el("div", "sl-epic-card sl-epic-notes");
971
+ notes.innerHTML = `<div class="sl-epic-label">Notes</div><div class="sl-epic-text">${escHtml(entity.notes)}</div>`;
972
+ panel.appendChild(notes);
973
+ }
974
+
975
+ shell.appendChild(panel);
976
+ return shell;
977
+ }
978
+
979
+ function renderEpicDetailEdit(entity) {
980
+ const dirty = dirtyEpicEdits[entity.id] || {
981
+ title: entity.title || "",
982
+ description: entity.description || "",
983
+ goals: entity.goals || "",
984
+ in_scope: (entity.in_scope || []).join("\n"),
985
+ out_of_scope: (entity.out_of_scope || []).join("\n"),
986
+ notes: entity.notes || ""
987
+ };
988
+ dirtyEpicEdits[entity.id] = dirty;
989
+
990
+ const shell = el("div", "sl-epic-shell");
991
+ shell.onclick = e => e.stopPropagation();
992
+
993
+ const grid = el("div", "sl-epic-edit-grid");
994
+
995
+ const titleWrap = el("div", "span2");
996
+ titleWrap.innerHTML = `<label>Title</label>`;
997
+ const titleInp = document.createElement("input");
998
+ titleInp.value = dirty.title;
999
+ titleInp.oninput = () => { dirty.title = titleInp.value; dirtyEpicEdits[entity.id] = dirty; };
1000
+ titleWrap.appendChild(titleInp);
1001
+ grid.appendChild(titleWrap);
1002
+
1003
+ const goalsWrap = el("div", "span2");
1004
+ goalsWrap.innerHTML = `<label>Goals</label>`;
1005
+ const goalsTa = document.createElement("textarea");
1006
+ goalsTa.value = dirty.goals;
1007
+ goalsTa.placeholder = "Outcome one-liner…";
1008
+ goalsTa.oninput = () => { dirty.goals = goalsTa.value; dirtyEpicEdits[entity.id] = dirty; };
1009
+ goalsWrap.appendChild(goalsTa);
1010
+ grid.appendChild(goalsWrap);
1011
+
1012
+ const descWrap = el("div", "span2");
1013
+ descWrap.innerHTML = `<label>Description</label>`;
1014
+ const descTa = document.createElement("textarea");
1015
+ descTa.value = dirty.description;
1016
+ descTa.placeholder = "Context for agents…";
1017
+ descTa.style.minHeight = "72px";
1018
+ descTa.oninput = () => { dirty.description = descTa.value; dirtyEpicEdits[entity.id] = dirty; };
1019
+ descWrap.appendChild(descTa);
1020
+ grid.appendChild(descWrap);
1021
+
1022
+ const inWrap = el("div", "");
1023
+ inWrap.innerHTML = `<label>In scope (one per line)</label>`;
1024
+ const inTa = document.createElement("textarea");
1025
+ inTa.value = dirty.in_scope;
1026
+ inTa.oninput = () => { dirty.in_scope = inTa.value; dirtyEpicEdits[entity.id] = dirty; };
1027
+ inWrap.appendChild(inTa);
1028
+ grid.appendChild(inWrap);
1029
+
1030
+ const outWrap = el("div", "");
1031
+ outWrap.innerHTML = `<label>Out of scope (one per line)</label>`;
1032
+ const outTa = document.createElement("textarea");
1033
+ outTa.value = dirty.out_of_scope;
1034
+ outTa.oninput = () => { dirty.out_of_scope = outTa.value; dirtyEpicEdits[entity.id] = dirty; };
1035
+ outWrap.appendChild(outTa);
1036
+ grid.appendChild(outWrap);
1037
+
1038
+ const notesWrap = el("div", "span2");
1039
+ notesWrap.innerHTML = `<label>Notes</label>`;
1040
+ const notesTa = document.createElement("textarea");
1041
+ notesTa.value = dirty.notes;
1042
+ notesTa.oninput = () => { dirty.notes = notesTa.value; dirtyEpicEdits[entity.id] = dirty; };
1043
+ notesWrap.appendChild(notesTa);
1044
+ grid.appendChild(notesWrap);
1045
+
1046
+ const actions = el("div", "sl-epic-edit-actions");
1047
+ const saveBtn = document.createElement("button");
1048
+ saveBtn.className = "btn-sm btn-confirm";
1049
+ saveBtn.textContent = "✓ Save epic";
1050
+ saveBtn.onclick = async e => { e.stopPropagation(); await saveEpicEdit(entity.id); };
1051
+ const cancelBtn = document.createElement("button");
1052
+ cancelBtn.className = "btn-sm btn-cancel";
1053
+ cancelBtn.textContent = "Cancel";
1054
+ cancelBtn.onclick = e => {
1055
+ e.stopPropagation();
1056
+ delete dirtyEpicEdits[entity.id];
1057
+ editingEpicId = null;
1058
+ render();
1059
+ };
1060
+ actions.appendChild(saveBtn);
1061
+ actions.appendChild(cancelBtn);
1062
+ grid.appendChild(actions);
1063
+
1064
+ shell.appendChild(grid);
1065
+ return shell;
1066
+ }
1067
+
1068
+ async function saveEpicEdit(epicId) {
1069
+ const dirty = dirtyEpicEdits[epicId];
1070
+ if (!dirty) { editingEpicId = null; render(); return; }
1071
+ const res = await fetch(`/api/epic-entities/${enc(epicId)}`, {
1072
+ method: "PATCH",
1073
+ headers: { "Content-Type": "application/json" },
1074
+ body: JSON.stringify({
1075
+ title: dirty.title.trim(),
1076
+ description: dirty.description,
1077
+ goals: dirty.goals,
1078
+ in_scope: splitLines(dirty.in_scope),
1079
+ out_of_scope: splitLines(dirty.out_of_scope),
1080
+ notes: dirty.notes
1081
+ })
1082
+ });
1083
+ if (res.ok) {
1084
+ delete dirtyEpicEdits[epicId];
1085
+ editingEpicId = null;
1086
+ toast("Epic saved ✓");
1087
+ await load();
1088
+ } else {
1089
+ toast("Epic save failed", true);
1090
+ }
1091
+ }
1092
+
604
1093
  // ── Swimlane ──────────────────────────────────────────────────────────────────
605
1094
 
606
- function renderSwimlane({ key, name, epicId, status, description, goals, tasks }) {
607
- const isCollapsed = collapsed.has(key);
1095
+ function toggleSet(set, key) {
1096
+ if (set.has(key)) set.delete(key);
1097
+ else set.add(key);
1098
+ }
1099
+
1100
+ function makeToggleBtn(label, isOn, onClick) {
1101
+ const btn = document.createElement("button");
1102
+ btn.type = "button";
1103
+ btn.className = `sl-toggle${isOn ? " on" : ""}`;
1104
+ btn.innerHTML = `<span class="chev">▶</span>${label}`;
1105
+ btn.onclick = e => { e.stopPropagation(); onClick(); };
1106
+ return btn;
1107
+ }
1108
+
1109
+ function renderSwimlane({ key, name, epicId, entity, tasks }) {
1110
+ const infoOpen = entity ? !collapsedInfo.has(key) : false;
1111
+ const tasksOpen = !collapsedTasks.has(key);
1112
+ const isEditingEpic = epicId && editingEpicId === epicId;
608
1113
 
609
1114
  const wrap = el("div", "swimlane");
610
1115
 
611
- // Header
612
- const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
1116
+ const hdr = el("div", "sl-header");
613
1117
  const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
614
1118
  const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
615
1119
  const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
616
1120
  const cardsDone = tasks.filter(t => t.column === "done").length;
617
- const statusBadge = status ? `<span class="sl-cnt" title="derived status">${escHtml(status)}</span>` : "";
618
-
619
- hdr.innerHTML = `
620
- <span class="sl-arrow">▼</span>
621
- <span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}${epicId ? ` <small style="opacity:.6">${escHtml(epicId)}</small>` : ""}</span>
1121
+ const status = entity ? (entity.status || "empty") : null;
1122
+
1123
+ const main = el("div", "sl-header-main");
1124
+ const titleHtml = name === ""
1125
+ ? "Uncategorized (no epic)"
1126
+ : escHtml(name);
1127
+ main.innerHTML = `
1128
+ <span class="sl-name">${titleHtml}</span>
1129
+ ${epicId ? `<span class="sl-id">${escHtml(epicId)}</span>` : ""}
1130
+ ${status ? `<span class="sl-status ${escHtml(status)}">${escHtml(status)}</span>` : ""}
622
1131
  <span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
623
- ${statusBadge}
624
- ${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% subtasks</span>` : ""}
1132
+ ${pct !== null ? `<span class="sl-progress">${pct}% subtasks</span>` : ""}
625
1133
  `;
626
- hdr.onclick = () => {
627
- if (collapsed.has(key)) collapsed.delete(key);
628
- else collapsed.add(key);
1134
+ hdr.appendChild(main);
1135
+
1136
+ const toggles = el("div", "sl-toggles");
1137
+
1138
+ if (entity) {
1139
+ toggles.appendChild(makeToggleBtn("Info", infoOpen, () => {
1140
+ toggleSet(collapsedInfo, key);
1141
+ render();
1142
+ }));
1143
+ }
1144
+
1145
+ toggles.appendChild(makeToggleBtn("Tasks", tasksOpen, () => {
1146
+ toggleSet(collapsedTasks, key);
629
1147
  render();
630
- };
1148
+ }));
1149
+
1150
+ if (epicId && entity) {
1151
+ const editBtn = document.createElement("button");
1152
+ editBtn.type = "button";
1153
+ editBtn.className = `sl-edit-btn${isEditingEpic ? " active" : ""}`;
1154
+ editBtn.textContent = isEditingEpic ? "Editing…" : "Edit";
1155
+ editBtn.onclick = e => {
1156
+ e.stopPropagation();
1157
+ if (editingEpicId === epicId) {
1158
+ editingEpicId = null;
1159
+ delete dirtyEpicEdits[epicId];
1160
+ } else {
1161
+ editingEpicId = epicId;
1162
+ collapsedInfo.delete(key);
1163
+ }
1164
+ render();
1165
+ };
1166
+ toggles.appendChild(editBtn);
1167
+
1168
+ const archBtn = document.createElement("button");
1169
+ archBtn.type = "button";
1170
+ archBtn.className = "sl-edit-btn";
1171
+ archBtn.textContent = entity.archived ? "Unarchive" : "Archive";
1172
+ archBtn.onclick = e => {
1173
+ e.stopPropagation();
1174
+ archiveEpic(epicId, !entity.archived);
1175
+ };
1176
+ toggles.appendChild(archBtn);
1177
+
1178
+ const delBtn = document.createElement("button");
1179
+ delBtn.type = "button";
1180
+ delBtn.className = "sl-edit-btn";
1181
+ delBtn.textContent = "Delete";
1182
+ delBtn.title = "Delete epic and all child tasks";
1183
+ delBtn.onclick = e => {
1184
+ e.stopPropagation();
1185
+ deleteEpicEntity(epicId, entity.title || name);
1186
+ };
1187
+ toggles.appendChild(delBtn);
1188
+ }
1189
+
1190
+ hdr.appendChild(toggles);
631
1191
  wrap.appendChild(hdr);
632
1192
 
633
- if (!isCollapsed && (description || goals)) {
634
- const meta = el("div", "sl-body");
635
- meta.style.cssText = "display:block;padding:6px 12px 0;font-size:12px;opacity:.85";
636
- meta.innerHTML = `${goals ? `<div><strong>Goals:</strong> ${escHtml(goals)}</div>` : ""}${description ? `<div>${escHtml(description)}</div>` : ""}`;
637
- wrap.appendChild(meta);
1193
+ if (entity && (infoOpen || isEditingEpic)) {
1194
+ wrap.appendChild(
1195
+ isEditingEpic ? renderEpicDetailEdit(entity) : renderEpicDetailView(entity)
1196
+ );
638
1197
  }
639
1198
 
640
- // Body
641
- const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
1199
+ const body = el("div", `sl-body${tasksOpen ? "" : " hidden"}`);
642
1200
  COL_DEFS.forEach(col => {
643
1201
  const lane = el("div", `lane col-${col.id}`);
644
1202
  tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
645
- // Add button only in non-done columns
646
1203
  if (col.id !== "done") {
647
1204
  const addBtn = document.createElement("button");
648
1205
  addBtn.className = "lane-add";
@@ -757,6 +1314,16 @@ function renderCard(epic, colId) {
757
1314
  editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
758
1315
  actions.appendChild(editBtn);
759
1316
 
1317
+ const delBtn = document.createElement("button");
1318
+ delBtn.className = "cbtn";
1319
+ delBtn.textContent = "✕";
1320
+ delBtn.title = "Delete task";
1321
+ delBtn.onclick = e => {
1322
+ e.stopPropagation();
1323
+ deleteTaskCard(epic.id, displayTitle);
1324
+ };
1325
+ actions.appendChild(delBtn);
1326
+
760
1327
  card.appendChild(actions);
761
1328
 
762
1329
  // Details + subtasks (expanded, view mode)
@@ -1072,9 +1639,11 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
1072
1639
  inp.focus();
1073
1640
  }
1074
1641
 
1075
- // ── New epic form (header) ────────────────────────────────────────────────────
1642
+ // ── New task / epic forms (header) ────────────────────────────────────────────
1076
1643
 
1077
1644
  function toggleNewForm() {
1645
+ const epicStrip = document.getElementById("new-epic-strip");
1646
+ if (epicStrip) epicStrip.classList.remove("open");
1078
1647
  const strip = document.getElementById("new-strip");
1079
1648
  strip.classList.toggle("open");
1080
1649
  if (strip.classList.contains("open")) {
@@ -1082,6 +1651,16 @@ function toggleNewForm() {
1082
1651
  }
1083
1652
  }
1084
1653
 
1654
+ function toggleNewEpicForm() {
1655
+ const taskStrip = document.getElementById("new-strip");
1656
+ if (taskStrip) taskStrip.classList.remove("open");
1657
+ const strip = document.getElementById("new-epic-strip");
1658
+ strip.classList.toggle("open");
1659
+ if (strip.classList.contains("open")) {
1660
+ document.getElementById("nx-title").focus();
1661
+ }
1662
+ }
1663
+
1085
1664
  async function submitNew() {
1086
1665
  const title = document.getElementById("ne-title").value.trim();
1087
1666
  const col = document.getElementById("ne-col").value;
@@ -1104,11 +1683,38 @@ async function submitNew() {
1104
1683
  }
1105
1684
  }
1106
1685
 
1686
+ async function submitNewEpic() {
1687
+ const title = document.getElementById("nx-title").value.trim();
1688
+ const description = document.getElementById("nx-description").value.trim();
1689
+ const goals = document.getElementById("nx-goals").value.trim();
1690
+ if (!title) { document.getElementById("nx-title").focus(); return; }
1691
+
1692
+ const res = await fetch("/api/epics", {
1693
+ method: "POST",
1694
+ headers: { "Content-Type": "application/json" },
1695
+ body: JSON.stringify({ title, description, goals }),
1696
+ });
1697
+ if (res.ok) {
1698
+ toast("Epic added ✓");
1699
+ document.getElementById("nx-title").value = "";
1700
+ document.getElementById("nx-description").value = "";
1701
+ document.getElementById("nx-goals").value = "";
1702
+ toggleNewEpicForm();
1703
+ await load();
1704
+ } else {
1705
+ toast("Error!", true);
1706
+ }
1707
+ }
1708
+
1107
1709
  // Key handler for new form
1108
1710
  document.getElementById("ne-title").addEventListener("keydown", e => {
1109
1711
  if (e.key === "Enter") submitNew();
1110
1712
  if (e.key === "Escape") toggleNewForm();
1111
1713
  });
1714
+ document.getElementById("nx-title").addEventListener("keydown", e => {
1715
+ if (e.key === "Enter") submitNewEpic();
1716
+ if (e.key === "Escape") toggleNewEpicForm();
1717
+ });
1112
1718
 
1113
1719
  // ── Utils ─────────────────────────────────────────────────────────────────────
1114
1720