kanbango 3.3.0 → 3.5.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/.ai/lessons.jsonl +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/.ait-quality.yml +25 -0
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +32 -4
- package/README.md +2 -2
- package/agent-playbook.js +12 -6
- package/bin/kanban.js +49 -14
- package/fenced-text.js +62 -0
- package/index.html +1305 -1168
- package/kanban.js +107 -82
- package/mcp-server.js +89 -144
- package/package.json +3 -2
- package/tests/run.js +4 -1
- package/.opencode/plans/action-error-recipe.md +0 -121
package/index.html
CHANGED
|
@@ -12,13 +12,15 @@
|
|
|
12
12
|
--planned: #2563eb;
|
|
13
13
|
--icebox: #64748b;
|
|
14
14
|
--done: #16a34a;
|
|
15
|
-
--bg: #
|
|
15
|
+
--bg: #e8eaef;
|
|
16
16
|
--card: #ffffff;
|
|
17
17
|
--border: #dde1e9;
|
|
18
18
|
--text: #1e293b;
|
|
19
19
|
--muted: #8fa0b8;
|
|
20
20
|
--radius: 8px;
|
|
21
|
-
--
|
|
21
|
+
--rail-w: 220px;
|
|
22
|
+
--paper-w: 380px;
|
|
23
|
+
--now-h: auto;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
body {
|
|
@@ -32,7 +34,7 @@ body {
|
|
|
32
34
|
flex-direction: column;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
/* ──
|
|
37
|
+
/* ── Topbar ── */
|
|
36
38
|
header {
|
|
37
39
|
padding: 0 16px;
|
|
38
40
|
height: 44px;
|
|
@@ -59,8 +61,11 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
59
61
|
font-family: inherit;
|
|
60
62
|
}
|
|
61
63
|
.btn-new:hover { filter: brightness(0.9); }
|
|
64
|
+
.btn-new.muted { background: #94a3b8; }
|
|
65
|
+
.btn-new.muted.on { background: #6366f1; }
|
|
66
|
+
.btn-new.secondary { background: #64748b; }
|
|
62
67
|
|
|
63
|
-
/* ── New
|
|
68
|
+
/* ── New form strips ── */
|
|
64
69
|
.new-epic-strip {
|
|
65
70
|
background: #fff;
|
|
66
71
|
border-bottom: 1px solid var(--border);
|
|
@@ -69,6 +74,7 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
69
74
|
gap: 8px;
|
|
70
75
|
align-items: center;
|
|
71
76
|
flex-shrink: 0;
|
|
77
|
+
flex-wrap: wrap;
|
|
72
78
|
}
|
|
73
79
|
.new-epic-strip.open { display: flex; }
|
|
74
80
|
.ne-field { display: flex; flex-direction: column; gap: 3px; }
|
|
@@ -90,9 +96,9 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
90
96
|
color: var(--text);
|
|
91
97
|
}
|
|
92
98
|
.ne-input:focus { border-color: var(--planned); }
|
|
93
|
-
.ne-title { width:
|
|
99
|
+
.ne-title { width: 220px; }
|
|
94
100
|
.ne-col { width: 130px; }
|
|
95
|
-
.ne-group { width:
|
|
101
|
+
.ne-group { width: 180px; }
|
|
96
102
|
.btn-sm {
|
|
97
103
|
border: none; cursor: pointer; border-radius: var(--radius);
|
|
98
104
|
font-size: 11px; padding: 4px 10px; font-weight: 600;
|
|
@@ -103,339 +109,203 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
103
109
|
.btn-cancel { background: #f1f5f9; color: var(--muted); }
|
|
104
110
|
.btn-cancel:hover { background: #e2e8f0; }
|
|
105
111
|
|
|
106
|
-
/* ──
|
|
107
|
-
|
|
112
|
+
/* ── Cockpit ── */
|
|
113
|
+
.cockpit {
|
|
108
114
|
flex: 1;
|
|
109
|
-
|
|
110
|
-
padding: 0 12px 16px;
|
|
111
|
-
}
|
|
112
|
-
#board-wrap::-webkit-scrollbar { width: 5px; }
|
|
113
|
-
#board-wrap::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
|
|
114
|
-
|
|
115
|
-
/* ── Sticky column headers ── */
|
|
116
|
-
.col-headers {
|
|
115
|
+
min-height: 0;
|
|
117
116
|
display: grid;
|
|
118
|
-
grid-template-columns: var(--
|
|
119
|
-
|
|
120
|
-
top: 0;
|
|
121
|
-
background: var(--bg);
|
|
122
|
-
z-index: 20;
|
|
123
|
-
padding: 8px 0 4px;
|
|
117
|
+
grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--paper-w);
|
|
118
|
+
gap: 0;
|
|
124
119
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
|
|
121
|
+
/* ── Rail ── */
|
|
122
|
+
#rail {
|
|
123
|
+
background: #f8fafc;
|
|
124
|
+
border-right: 1px solid var(--border);
|
|
125
|
+
overflow-y: auto;
|
|
126
|
+
padding: 10px 8px;
|
|
131
127
|
display: flex;
|
|
132
|
-
|
|
133
|
-
gap:
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 4px;
|
|
134
130
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
padding: 1px 6px;
|
|
131
|
+
#rail::-webkit-scrollbar { width: 4px; }
|
|
132
|
+
#rail::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 2px; }
|
|
133
|
+
.rail-label {
|
|
139
134
|
font-size: 10px;
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: 0.6px;
|
|
140
138
|
color: var(--muted);
|
|
141
|
-
|
|
139
|
+
padding: 4px 8px 6px;
|
|
142
140
|
}
|
|
143
|
-
.
|
|
144
|
-
.ch.planned { color: var(--planned); }
|
|
145
|
-
.ch.active { color: var(--active); }
|
|
146
|
-
.ch.done { color: var(--done); }
|
|
147
|
-
|
|
148
|
-
/* ── Swimlane ── */
|
|
149
|
-
.swimlane {
|
|
150
|
-
background: #fff;
|
|
151
|
-
border: 1px solid var(--border);
|
|
152
|
-
border-radius: var(--radius);
|
|
153
|
-
margin-bottom: 6px;
|
|
154
|
-
overflow: hidden;
|
|
155
|
-
}
|
|
156
|
-
.sl-header {
|
|
141
|
+
.rail-item {
|
|
157
142
|
display: flex;
|
|
158
143
|
align-items: center;
|
|
159
144
|
gap: 8px;
|
|
160
|
-
padding: 8px
|
|
161
|
-
|
|
162
|
-
border
|
|
163
|
-
|
|
145
|
+
padding: 8px 10px;
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
border: 1px solid transparent;
|
|
148
|
+
background: transparent;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
text-align: left;
|
|
151
|
+
font-family: inherit;
|
|
152
|
+
color: var(--text);
|
|
153
|
+
width: 100%;
|
|
164
154
|
}
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
155
|
+
.rail-item:hover { background: #fff; border-color: var(--border); }
|
|
156
|
+
.rail-item.on {
|
|
157
|
+
background: #fff;
|
|
158
|
+
border-color: #c7d2fe;
|
|
159
|
+
box-shadow: 0 1px 3px rgba(99,102,241,0.12);
|
|
160
|
+
}
|
|
161
|
+
.rail-item .ri-title {
|
|
169
162
|
flex: 1;
|
|
170
163
|
min-width: 0;
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
white-space: nowrap;
|
|
167
|
+
overflow: hidden;
|
|
168
|
+
text-overflow: ellipsis;
|
|
171
169
|
}
|
|
172
|
-
.
|
|
173
|
-
.sl-id {
|
|
170
|
+
.rail-item .ri-id {
|
|
174
171
|
font-size: 10px;
|
|
175
172
|
font-weight: 700;
|
|
176
173
|
color: var(--muted);
|
|
177
174
|
background: #e2e8f0;
|
|
178
175
|
border-radius: 4px;
|
|
179
|
-
padding: 1px
|
|
180
|
-
|
|
176
|
+
padding: 1px 5px;
|
|
177
|
+
flex-shrink: 0;
|
|
181
178
|
}
|
|
182
|
-
.
|
|
183
|
-
font-size:
|
|
179
|
+
.rail-item .ri-cnt {
|
|
180
|
+
font-size: 10px;
|
|
181
|
+
font-weight: 700;
|
|
184
182
|
color: var(--muted);
|
|
185
|
-
|
|
186
|
-
border-radius: 10px;
|
|
187
|
-
padding: 1px 6px;
|
|
188
|
-
font-weight: 600;
|
|
183
|
+
flex-shrink: 0;
|
|
189
184
|
}
|
|
190
|
-
.
|
|
191
|
-
font-size:
|
|
185
|
+
.rail-item .ri-status {
|
|
186
|
+
font-size: 9px;
|
|
192
187
|
font-weight: 700;
|
|
193
188
|
text-transform: uppercase;
|
|
194
|
-
letter-spacing: 0.
|
|
189
|
+
letter-spacing: 0.3px;
|
|
195
190
|
border-radius: 999px;
|
|
196
|
-
padding:
|
|
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; }
|
|
203
|
-
.sl-progress {
|
|
204
|
-
font-size: 11px;
|
|
205
|
-
color: var(--muted);
|
|
206
|
-
white-space: nowrap;
|
|
191
|
+
padding: 1px 6px;
|
|
192
|
+
flex-shrink: 0;
|
|
207
193
|
}
|
|
208
|
-
.
|
|
194
|
+
.ri-status.empty { background: #f1f5f9; color: #64748b; }
|
|
195
|
+
.ri-status.planned { background: #dbeafe; color: #1d4ed8; }
|
|
196
|
+
.ri-status.active { background: #ffedd5; color: #c2410c; }
|
|
197
|
+
.ri-status.done { background: #dcfce7; color: #15803d; }
|
|
198
|
+
.ri-status.archived { background: #f1f5f9; color: #64748b; }
|
|
199
|
+
|
|
200
|
+
/* ── Board center ── */
|
|
201
|
+
.board-center {
|
|
209
202
|
display: flex;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
flex-direction: column;
|
|
204
|
+
min-width: 0;
|
|
205
|
+
min-height: 0;
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ── NOW ── */
|
|
210
|
+
#now {
|
|
213
211
|
flex-shrink: 0;
|
|
212
|
+
background: linear-gradient(180deg, #fff7ed 0%, #ffedd5 100%);
|
|
213
|
+
border-bottom: 1px solid #fed7aa;
|
|
214
|
+
padding: 10px 12px 12px;
|
|
215
|
+
max-height: 42%;
|
|
216
|
+
overflow-y: auto;
|
|
214
217
|
}
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
background: #fff;
|
|
218
|
-
color: var(--muted);
|
|
219
|
-
border-radius: 6px;
|
|
220
|
-
font-size: 11px;
|
|
221
|
-
font-weight: 600;
|
|
222
|
-
padding: 3px 9px;
|
|
223
|
-
cursor: pointer;
|
|
224
|
-
font-family: inherit;
|
|
225
|
-
display: inline-flex;
|
|
218
|
+
.now-head {
|
|
219
|
+
display: flex;
|
|
226
220
|
align-items: center;
|
|
227
|
-
gap:
|
|
221
|
+
gap: 8px;
|
|
222
|
+
margin-bottom: 8px;
|
|
228
223
|
}
|
|
229
|
-
.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
.now-title {
|
|
225
|
+
font-size: 11px;
|
|
226
|
+
font-weight: 800;
|
|
227
|
+
letter-spacing: 0.8px;
|
|
228
|
+
text-transform: uppercase;
|
|
229
|
+
color: var(--active);
|
|
234
230
|
}
|
|
235
|
-
.
|
|
236
|
-
.sl-toggle.on .chev { transform: rotate(90deg); }
|
|
237
|
-
.sl-edit-btn {
|
|
238
|
-
border: none;
|
|
239
|
-
background: #e2e8f0;
|
|
240
|
-
color: var(--muted);
|
|
241
|
-
border-radius: 6px;
|
|
231
|
+
.now-count {
|
|
242
232
|
font-size: 10px;
|
|
243
233
|
font-weight: 700;
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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;
|
|
234
|
+
background: rgba(232,93,4,0.12);
|
|
235
|
+
color: var(--active);
|
|
236
|
+
border-radius: 10px;
|
|
237
|
+
padding: 1px 7px;
|
|
262
238
|
}
|
|
263
|
-
.
|
|
239
|
+
.now-cards {
|
|
264
240
|
display: flex;
|
|
265
241
|
flex-wrap: wrap;
|
|
266
|
-
gap: 8px
|
|
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;
|
|
242
|
+
gap: 8px;
|
|
285
243
|
}
|
|
286
|
-
.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
gap: 6px;
|
|
290
|
-
align-content: flex-start;
|
|
244
|
+
.now-cards .card {
|
|
245
|
+
width: 240px;
|
|
246
|
+
max-width: 100%;
|
|
291
247
|
}
|
|
292
|
-
.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
border-radius: 999px;
|
|
299
|
-
padding: 3px 10px;
|
|
300
|
-
font-size: 11px;
|
|
301
|
-
font-weight: 600;
|
|
302
|
-
color: #475569;
|
|
248
|
+
.now-empty {
|
|
249
|
+
font-size: 12px;
|
|
250
|
+
color: #c2410c;
|
|
251
|
+
opacity: 0.7;
|
|
252
|
+
font-style: italic;
|
|
253
|
+
padding: 6px 0;
|
|
303
254
|
}
|
|
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
255
|
|
|
310
|
-
|
|
256
|
+
/* ── Columns ── */
|
|
257
|
+
#board-cols {
|
|
258
|
+
flex: 1;
|
|
259
|
+
min-height: 0;
|
|
311
260
|
display: grid;
|
|
312
|
-
grid-template-columns:
|
|
313
|
-
gap:
|
|
314
|
-
|
|
315
|
-
@media (max-width: 900px) {
|
|
316
|
-
.sl-epic-body { grid-template-columns: 1fr; }
|
|
261
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
262
|
+
gap: 0;
|
|
263
|
+
overflow: hidden;
|
|
317
264
|
}
|
|
318
|
-
.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
border-radius: 8px;
|
|
322
|
-
padding: 10px 12px;
|
|
265
|
+
.col {
|
|
266
|
+
display: flex;
|
|
267
|
+
flex-direction: column;
|
|
323
268
|
min-width: 0;
|
|
269
|
+
border-right: 1px solid var(--border);
|
|
270
|
+
background: #f4f5f8;
|
|
324
271
|
}
|
|
325
|
-
.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
330
|
-
.sl-epic-label {
|
|
331
|
-
font-size: 10px;
|
|
272
|
+
.col:last-child { border-right: none; }
|
|
273
|
+
.col-head {
|
|
274
|
+
padding: 8px 12px;
|
|
275
|
+
font-size: 11px;
|
|
332
276
|
font-weight: 700;
|
|
333
277
|
text-transform: uppercase;
|
|
334
|
-
letter-spacing: 0.
|
|
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;
|
|
278
|
+
letter-spacing: 0.7px;
|
|
349
279
|
display: flex;
|
|
350
|
-
|
|
351
|
-
gap:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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;
|
|
280
|
+
align-items: center;
|
|
281
|
+
gap: 6px;
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
background: #eef0f3;
|
|
284
|
+
border-bottom: 1px solid var(--border);
|
|
386
285
|
}
|
|
387
|
-
.
|
|
388
|
-
.
|
|
389
|
-
|
|
286
|
+
.col-head.icebox { color: var(--icebox); }
|
|
287
|
+
.col-head.planned { color: var(--planned); }
|
|
288
|
+
.col-head.done { color: var(--done); }
|
|
289
|
+
.ch-count {
|
|
290
|
+
background: rgba(0,0,0,0.07);
|
|
291
|
+
border-radius: 10px;
|
|
292
|
+
padding: 1px 6px;
|
|
390
293
|
font-size: 10px;
|
|
391
|
-
font-weight: 700;
|
|
392
|
-
text-transform: uppercase;
|
|
393
|
-
letter-spacing: 0.5px;
|
|
394
294
|
color: var(--muted);
|
|
395
|
-
|
|
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;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/* ── Swimlane body grid ── */
|
|
419
|
-
.sl-body {
|
|
420
|
-
display: grid;
|
|
421
|
-
grid-template-columns: var(--col-w);
|
|
422
|
-
min-height: 60px;
|
|
295
|
+
font-weight: 600;
|
|
423
296
|
}
|
|
424
|
-
.
|
|
425
|
-
|
|
426
|
-
|
|
297
|
+
.col-body {
|
|
298
|
+
flex: 1;
|
|
299
|
+
overflow-y: auto;
|
|
427
300
|
padding: 8px;
|
|
428
|
-
min-height: 56px;
|
|
429
|
-
border-right: 1px solid #f1f5f9;
|
|
430
301
|
display: flex;
|
|
431
302
|
flex-direction: column;
|
|
432
303
|
gap: 6px;
|
|
433
304
|
}
|
|
434
|
-
.
|
|
435
|
-
.
|
|
436
|
-
.lane.col-done { background: #fafdfb; }
|
|
305
|
+
.col-body::-webkit-scrollbar { width: 4px; }
|
|
306
|
+
.col-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 2px; }
|
|
437
307
|
|
|
438
|
-
/* ──
|
|
308
|
+
/* ── Cards ── */
|
|
439
309
|
.card {
|
|
440
310
|
background: var(--card);
|
|
441
311
|
border-radius: var(--radius);
|
|
@@ -447,16 +317,17 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
447
317
|
position: relative;
|
|
448
318
|
}
|
|
449
319
|
.card:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.11); }
|
|
450
|
-
|
|
320
|
+
.card.selected {
|
|
321
|
+
box-shadow: 0 0 0 2px var(--planned);
|
|
322
|
+
}
|
|
451
323
|
.card.col-planned { border-left-color: var(--planned); }
|
|
452
324
|
.card.col-active {
|
|
453
325
|
border-left-color: var(--active);
|
|
454
326
|
background: #fffdf9;
|
|
455
327
|
box-shadow: 0 2px 6px rgba(232,93,4,0.10);
|
|
456
328
|
}
|
|
457
|
-
.card.col-
|
|
458
|
-
|
|
459
|
-
/* Icebox: tile style */
|
|
329
|
+
.card.col-active.selected { box-shadow: 0 0 0 2px var(--active); }
|
|
330
|
+
.card.col-done { border-left-color: var(--done); opacity: 0.7; }
|
|
460
331
|
.card.col-icebox {
|
|
461
332
|
background: #edf0f7;
|
|
462
333
|
border-left: none;
|
|
@@ -473,107 +344,69 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
473
344
|
background: rgba(0,0,0,0.05); border-radius: 4px;
|
|
474
345
|
padding: 1px 5px; white-space: nowrap;
|
|
475
346
|
}
|
|
476
|
-
.card
|
|
477
|
-
.card-title { font-size:
|
|
478
|
-
.card.col-icebox .card-title { font-size: 13px; font-weight: 500; color: #475569; }
|
|
347
|
+
.card-title { font-size: 13px; font-weight: 600; line-height: 1.3; word-break: break-word; color: var(--text); }
|
|
348
|
+
.card.col-icebox .card-title { font-size: 12px; font-weight: 500; color: #475569; }
|
|
479
349
|
.card.col-done .card-title { text-decoration: line-through; color: var(--muted); }
|
|
480
350
|
|
|
481
|
-
/* ── Progress bar ── */
|
|
482
351
|
.prog-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
|
483
352
|
.prog-bar { flex: 1; height: 4px; background: #dde1e9; border-radius: 2px; overflow: hidden; }
|
|
484
|
-
.prog-fill { height: 100%; border-radius: 2px;
|
|
353
|
+
.prog-fill { height: 100%; border-radius: 2px; }
|
|
485
354
|
.prog-fill.planned { background: var(--planned); }
|
|
486
355
|
.prog-fill.active { background: var(--active); }
|
|
487
356
|
.prog-fill.done { background: var(--done); }
|
|
488
357
|
.prog-fill.icebox { background: var(--icebox); }
|
|
489
358
|
.prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
490
359
|
|
|
491
|
-
/* ── Card action bar ── */
|
|
492
360
|
.card-actions {
|
|
493
361
|
display: flex; gap: 4px; margin-top: 8px;
|
|
494
362
|
opacity: 0; transition: opacity 0.12s;
|
|
495
363
|
}
|
|
496
364
|
.card:hover .card-actions,
|
|
497
|
-
.card.
|
|
498
|
-
.card.editing .card-actions { opacity: 1; }
|
|
365
|
+
.card.selected .card-actions { opacity: 1; }
|
|
499
366
|
|
|
500
367
|
.cbtn {
|
|
501
368
|
border: none; cursor: pointer; border-radius: 5px;
|
|
502
369
|
font-size: 11px; padding: 3px 8px; font-weight: 600;
|
|
503
370
|
font-family: inherit; background: #edf0f5; color: var(--muted);
|
|
504
|
-
transition: background 0.1s, color 0.1s;
|
|
505
371
|
}
|
|
506
372
|
.cbtn:hover { background: #dde4f0; color: var(--text); }
|
|
507
|
-
|
|
508
|
-
/* Arrow move buttons */
|
|
509
|
-
.cbtn.arr {
|
|
510
|
-
padding: 3px 9px;
|
|
511
|
-
font-size: 13px;
|
|
512
|
-
line-height: 1;
|
|
513
|
-
}
|
|
373
|
+
.cbtn.arr { padding: 3px 9px; font-size: 13px; line-height: 1; }
|
|
514
374
|
.cbtn.arr:hover { background: #d0d8f0; color: var(--planned); }
|
|
515
|
-
.card.col-active .cbtn.arr:hover { background: #ffe4d0; color: var(--active); }
|
|
516
|
-
|
|
517
|
-
.cbtn.edit-btn { color: #5a7ab5; }
|
|
518
|
-
.cbtn.edit-btn:hover { background: #e8efff; color: var(--planned); }
|
|
519
375
|
|
|
520
|
-
|
|
521
|
-
.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
border-top: 1px solid #edf0f5;
|
|
526
|
-
}
|
|
527
|
-
.tasks-list {
|
|
528
|
-
display: flex; flex-direction: column; gap: 4px;
|
|
529
|
-
}
|
|
530
|
-
.task-row { display: flex; align-items: flex-start; gap: 7px; }
|
|
531
|
-
.task-row input[type="checkbox"] {
|
|
532
|
-
margin-top: 2px; cursor: pointer; flex-shrink: 0;
|
|
533
|
-
accent-color: var(--planned);
|
|
376
|
+
.lane-add {
|
|
377
|
+
background: none; border: 1.5px dashed #d1d5db; border-radius: var(--radius);
|
|
378
|
+
padding: 5px 10px; font-size: 11px; color: var(--muted); cursor: pointer;
|
|
379
|
+
width: 100%; text-align: left; font-family: inherit;
|
|
380
|
+
transition: all 0.15s; margin-top: 2px;
|
|
534
381
|
}
|
|
535
|
-
.
|
|
536
|
-
|
|
537
|
-
.
|
|
382
|
+
.lane-add:hover { border-color: var(--planned); color: var(--planned); background: #eff6ff; }
|
|
383
|
+
|
|
384
|
+
.lane-addinp {
|
|
385
|
+
background: #fff;
|
|
386
|
+
border: 1px solid var(--border);
|
|
387
|
+
border-radius: var(--radius);
|
|
388
|
+
padding: 7px;
|
|
389
|
+
margin-top: 2px;
|
|
538
390
|
display: flex;
|
|
539
391
|
flex-direction: column;
|
|
540
|
-
gap:
|
|
392
|
+
gap: 6px;
|
|
541
393
|
}
|
|
542
|
-
.
|
|
543
|
-
font-size:
|
|
394
|
+
.lane-add-label {
|
|
395
|
+
font-size: 10px;
|
|
396
|
+
font-weight: 700;
|
|
397
|
+
text-transform: uppercase;
|
|
398
|
+
letter-spacing: 0.6px;
|
|
544
399
|
color: var(--muted);
|
|
545
|
-
line-height: 1.4;
|
|
546
400
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
401
|
+
.lane-add-title {
|
|
402
|
+
width: 100%;
|
|
403
|
+
border: 1px solid var(--planned);
|
|
404
|
+
border-radius: 4px;
|
|
405
|
+
padding: 4px 7px;
|
|
406
|
+
font-size: 12px;
|
|
407
|
+
font-family: inherit;
|
|
408
|
+
outline: none;
|
|
555
409
|
}
|
|
556
|
-
|
|
557
|
-
.edit-tasks { margin-top: 9px; border-top: 1px solid #f1f5f9; padding-top: 9px; }
|
|
558
|
-
.edit-task-row {
|
|
559
|
-
display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
|
|
560
|
-
}
|
|
561
|
-
.edit-task-row input[type="checkbox"] { flex-shrink: 0; accent-color: var(--planned); cursor: pointer; }
|
|
562
|
-
.edit-task-input {
|
|
563
|
-
flex: 1; border: 1px solid transparent; border-radius: 3px;
|
|
564
|
-
padding: 2px 5px; font-size: 12px; font-family: inherit;
|
|
565
|
-
color: var(--text); outline: none; background: transparent;
|
|
566
|
-
}
|
|
567
|
-
.edit-task-input:focus { border-color: var(--planned); background: #fff; }
|
|
568
|
-
.edit-task-input:hover { border-color: var(--border); }
|
|
569
|
-
.edit-task-fields {
|
|
570
|
-
flex: 1;
|
|
571
|
-
display: flex;
|
|
572
|
-
flex-direction: column;
|
|
573
|
-
gap: 4px;
|
|
574
|
-
}
|
|
575
|
-
.edit-task-desc,
|
|
576
|
-
.edit-block,
|
|
577
410
|
.lane-add-textarea {
|
|
578
411
|
width: 100%;
|
|
579
412
|
border: 1px solid #d7deea;
|
|
@@ -584,21 +417,88 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
584
417
|
color: var(--text);
|
|
585
418
|
outline: none;
|
|
586
419
|
resize: vertical;
|
|
587
|
-
min-height:
|
|
420
|
+
min-height: 48px;
|
|
588
421
|
background: #fff;
|
|
589
422
|
}
|
|
590
|
-
.
|
|
591
|
-
|
|
423
|
+
.lane-add-actions { display: flex; gap: 6px; }
|
|
424
|
+
|
|
425
|
+
/* ── Paper ── */
|
|
426
|
+
#paper {
|
|
427
|
+
background: #fff;
|
|
428
|
+
border-left: 1px solid var(--border);
|
|
429
|
+
display: flex;
|
|
430
|
+
flex-direction: column;
|
|
431
|
+
min-height: 0;
|
|
432
|
+
overflow: hidden;
|
|
592
433
|
}
|
|
593
|
-
.
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
434
|
+
.paper-empty {
|
|
435
|
+
flex: 1;
|
|
436
|
+
display: flex;
|
|
437
|
+
align-items: center;
|
|
438
|
+
justify-content: center;
|
|
439
|
+
color: var(--muted);
|
|
440
|
+
font-size: 13px;
|
|
441
|
+
padding: 24px;
|
|
442
|
+
text-align: center;
|
|
443
|
+
line-height: 1.5;
|
|
597
444
|
}
|
|
445
|
+
.paper-head {
|
|
446
|
+
padding: 12px 14px 10px;
|
|
447
|
+
border-bottom: 1px solid var(--border);
|
|
448
|
+
flex-shrink: 0;
|
|
449
|
+
display: flex;
|
|
450
|
+
flex-direction: column;
|
|
451
|
+
gap: 8px;
|
|
452
|
+
}
|
|
453
|
+
.paper-kicker {
|
|
454
|
+
display: flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
gap: 6px;
|
|
457
|
+
flex-wrap: wrap;
|
|
458
|
+
}
|
|
459
|
+
.paper-kind {
|
|
460
|
+
font-size: 10px;
|
|
461
|
+
font-weight: 800;
|
|
462
|
+
letter-spacing: 0.6px;
|
|
463
|
+
text-transform: uppercase;
|
|
464
|
+
color: var(--muted);
|
|
465
|
+
}
|
|
466
|
+
.paper-badge {
|
|
467
|
+
font-size: 10px;
|
|
468
|
+
font-weight: 700;
|
|
469
|
+
background: #e2e8f0;
|
|
470
|
+
color: #475569;
|
|
471
|
+
border-radius: 4px;
|
|
472
|
+
padding: 1px 6px;
|
|
473
|
+
}
|
|
474
|
+
.paper-badge.col-active { background: #ffedd5; color: #c2410c; }
|
|
475
|
+
.paper-badge.col-planned { background: #dbeafe; color: #1d4ed8; }
|
|
476
|
+
.paper-badge.col-icebox { background: #e2e8f0; color: #64748b; }
|
|
477
|
+
.paper-badge.col-done { background: #dcfce7; color: #15803d; }
|
|
478
|
+
.paper-title {
|
|
479
|
+
font-size: 16px;
|
|
480
|
+
font-weight: 700;
|
|
481
|
+
line-height: 1.3;
|
|
482
|
+
word-break: break-word;
|
|
483
|
+
}
|
|
484
|
+
.paper-actions {
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-wrap: wrap;
|
|
487
|
+
gap: 6px;
|
|
488
|
+
}
|
|
489
|
+
.paper-body {
|
|
490
|
+
flex: 1;
|
|
491
|
+
overflow-y: auto;
|
|
492
|
+
padding: 12px 14px 20px;
|
|
493
|
+
display: flex;
|
|
494
|
+
flex-direction: column;
|
|
495
|
+
gap: 12px;
|
|
496
|
+
}
|
|
497
|
+
.paper-body::-webkit-scrollbar { width: 5px; }
|
|
498
|
+
.paper-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 3px; }
|
|
499
|
+
|
|
598
500
|
.detail-section {
|
|
599
|
-
|
|
600
|
-
padding-top: 8px;
|
|
601
|
-
border-top: 1px solid #edf0f5;
|
|
501
|
+
padding-top: 2px;
|
|
602
502
|
}
|
|
603
503
|
.detail-label {
|
|
604
504
|
font-size: 10px;
|
|
@@ -606,14 +506,17 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
606
506
|
text-transform: uppercase;
|
|
607
507
|
letter-spacing: 0.6px;
|
|
608
508
|
color: var(--muted);
|
|
609
|
-
margin-bottom:
|
|
509
|
+
margin-bottom: 5px;
|
|
610
510
|
}
|
|
611
511
|
.detail-text {
|
|
612
512
|
font-size: 12px;
|
|
613
|
-
line-height: 1.
|
|
513
|
+
line-height: 1.5;
|
|
614
514
|
color: var(--text);
|
|
615
515
|
white-space: pre-wrap;
|
|
516
|
+
word-break: break-word;
|
|
616
517
|
}
|
|
518
|
+
.detail-text .md-text { white-space: pre-wrap; }
|
|
519
|
+
.detail-text.empty { color: #94a3b8; font-style: italic; }
|
|
617
520
|
.detail-list {
|
|
618
521
|
margin: 0;
|
|
619
522
|
padding-left: 18px;
|
|
@@ -625,86 +528,167 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
625
528
|
border-left: 3px solid #e8b4b4;
|
|
626
529
|
padding-left: 8px;
|
|
627
530
|
}
|
|
628
|
-
.detail-section.out-of-scope .detail-label {
|
|
629
|
-
|
|
531
|
+
.detail-section.out-of-scope .detail-label { color: #a86a6a; }
|
|
532
|
+
.detail-section.out-of-scope .detail-list { color: #7a5a5a; }
|
|
533
|
+
|
|
534
|
+
.scope-pair {
|
|
535
|
+
display: grid;
|
|
536
|
+
grid-template-columns: 1fr 1fr;
|
|
537
|
+
gap: 10px;
|
|
630
538
|
}
|
|
631
|
-
.
|
|
632
|
-
|
|
539
|
+
.chip-row {
|
|
540
|
+
display: flex;
|
|
541
|
+
flex-wrap: wrap;
|
|
542
|
+
gap: 6px;
|
|
633
543
|
}
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
|
|
544
|
+
.sl-chip {
|
|
545
|
+
display: inline-flex;
|
|
546
|
+
align-items: center;
|
|
547
|
+
gap: 4px;
|
|
548
|
+
background: #f8fafc;
|
|
549
|
+
border: 1px solid #e2e8f0;
|
|
550
|
+
border-radius: 999px;
|
|
551
|
+
padding: 3px 10px;
|
|
552
|
+
font-size: 11px;
|
|
553
|
+
font-weight: 600;
|
|
554
|
+
color: #475569;
|
|
637
555
|
}
|
|
638
|
-
.
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
556
|
+
.sl-chip strong { color: #0f172a; font-weight: 700; }
|
|
557
|
+
.sl-chip.done { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
|
|
558
|
+
.sl-chip.active { border-color: #fed7aa; background: #fff7ed; color: #c2410c; }
|
|
559
|
+
.sl-chip.planned { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
|
|
560
|
+
.sl-chip.icebox { border-color: #e2e8f0; background: #f8fafc; color: #64748b; }
|
|
561
|
+
|
|
562
|
+
.subtask-label {
|
|
563
|
+
font-size: 10px; font-weight: 700; text-transform: uppercase;
|
|
564
|
+
letter-spacing: 0.6px; color: var(--muted);
|
|
565
|
+
margin-bottom: 6px;
|
|
645
566
|
}
|
|
646
|
-
.
|
|
567
|
+
.tasks-list { display: flex; flex-direction: column; gap: 4px; }
|
|
568
|
+
.task-row { display: flex; align-items: flex-start; gap: 7px; }
|
|
569
|
+
.task-row input[type="checkbox"] {
|
|
570
|
+
margin-top: 2px; cursor: pointer; flex-shrink: 0;
|
|
571
|
+
accent-color: var(--planned);
|
|
572
|
+
}
|
|
573
|
+
.task-row label { font-size: 12px; cursor: pointer; line-height: 1.4; color: var(--text); }
|
|
574
|
+
.task-row.done label { text-decoration: line-through; color: var(--muted); }
|
|
575
|
+
.task-copy { display: flex; flex-direction: column; gap: 2px; }
|
|
576
|
+
.task-desc { font-size: 11px; color: var(--muted); line-height: 1.4; }
|
|
577
|
+
|
|
578
|
+
.adr-card, .evidence-card {
|
|
579
|
+
background: #f8fafc;
|
|
580
|
+
border: 1px solid #e2e8f0;
|
|
581
|
+
border-radius: 8px;
|
|
582
|
+
padding: 8px 10px;
|
|
583
|
+
margin-bottom: 6px;
|
|
584
|
+
}
|
|
585
|
+
.adr-card .adr-decision { font-size: 12px; font-weight: 700; margin-bottom: 3px; }
|
|
586
|
+
.adr-card .adr-why { font-size: 12px; color: #475569; line-height: 1.4; }
|
|
587
|
+
.adr-card .adr-meta, .evidence-card .ev-meta {
|
|
588
|
+
font-size: 10px; color: var(--muted); margin-top: 4px;
|
|
589
|
+
}
|
|
590
|
+
.evidence-card .ev-cmd {
|
|
591
|
+
font-size: 11px;
|
|
592
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
647
593
|
background: #fff;
|
|
648
|
-
border:
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
display: flex;
|
|
653
|
-
flex-direction: column;
|
|
654
|
-
gap: 6px;
|
|
594
|
+
border-radius: 4px;
|
|
595
|
+
padding: 4px 6px;
|
|
596
|
+
margin-bottom: 4px;
|
|
597
|
+
word-break: break-all;
|
|
655
598
|
}
|
|
656
|
-
.
|
|
657
|
-
font-size:
|
|
658
|
-
font-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
599
|
+
.evidence-card .ev-diff {
|
|
600
|
+
font-size: 11px;
|
|
601
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
602
|
+
white-space: pre-wrap;
|
|
603
|
+
max-height: 120px;
|
|
604
|
+
overflow: auto;
|
|
605
|
+
color: #334155;
|
|
662
606
|
}
|
|
663
|
-
|
|
607
|
+
|
|
608
|
+
/* Paper edit */
|
|
609
|
+
.edit-title-input {
|
|
610
|
+
width: 100%; border: 1px solid var(--planned); border-radius: 4px;
|
|
611
|
+
padding: 6px 8px; font-size: 15px; font-weight: 600; font-family: inherit;
|
|
612
|
+
color: var(--text); outline: none;
|
|
613
|
+
}
|
|
614
|
+
.edit-block, .edit-task-desc {
|
|
664
615
|
width: 100%;
|
|
665
|
-
border: 1px solid
|
|
616
|
+
border: 1px solid #d7deea;
|
|
666
617
|
border-radius: 4px;
|
|
667
|
-
padding:
|
|
618
|
+
padding: 5px 7px;
|
|
668
619
|
font-size: 12px;
|
|
669
620
|
font-family: inherit;
|
|
621
|
+
color: var(--text);
|
|
670
622
|
outline: none;
|
|
623
|
+
resize: vertical;
|
|
624
|
+
min-height: 56px;
|
|
625
|
+
background: #fff;
|
|
671
626
|
}
|
|
672
|
-
.
|
|
673
|
-
|
|
674
|
-
|
|
627
|
+
.edit-block:focus, .edit-task-desc:focus { border-color: var(--planned); }
|
|
628
|
+
.edit-block.out-of-scope { border-color: #e8c4c4; background: #fffbfb; }
|
|
629
|
+
.edit-task-row {
|
|
630
|
+
display: flex; align-items: flex-start; gap: 6px; margin-bottom: 6px;
|
|
675
631
|
}
|
|
632
|
+
.edit-task-row input[type="checkbox"] { margin-top: 6px; flex-shrink: 0; accent-color: var(--planned); cursor: pointer; }
|
|
633
|
+
.edit-task-fields { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
|
634
|
+
.edit-task-input {
|
|
635
|
+
width: 100%; border: 1px solid transparent; border-radius: 3px;
|
|
636
|
+
padding: 3px 5px; font-size: 12px; font-family: inherit;
|
|
637
|
+
color: var(--text); outline: none; background: transparent;
|
|
638
|
+
}
|
|
639
|
+
.edit-task-input:focus { border-color: var(--planned); background: #fff; }
|
|
676
640
|
.del-task {
|
|
677
641
|
flex-shrink: 0; border: none; background: none; cursor: pointer;
|
|
678
|
-
color: var(--muted); font-size: 14px; line-height: 1; padding:
|
|
679
|
-
opacity: 0; transition: opacity 0.1s;
|
|
642
|
+
color: var(--muted); font-size: 14px; line-height: 1; padding: 4px 2px;
|
|
680
643
|
}
|
|
681
|
-
.edit-task-row:hover .del-task { opacity: 1; }
|
|
682
644
|
.del-task:hover { color: #dc2626; }
|
|
683
|
-
|
|
684
|
-
.add-task-row { margin-top: 5px; }
|
|
685
645
|
.add-task-inp {
|
|
686
646
|
width: 100%; border: 1px dashed #cbd5e1; border-radius: 3px;
|
|
687
|
-
padding:
|
|
647
|
+
padding: 4px 7px; font-size: 12px; font-family: inherit;
|
|
688
648
|
color: var(--text); outline: none; background: transparent;
|
|
689
649
|
}
|
|
690
650
|
.add-task-inp:focus { border-color: var(--planned); border-style: solid; background: #fff; }
|
|
691
|
-
.add-task-inp::placeholder { color: var(--muted); }
|
|
692
|
-
|
|
693
651
|
.edit-footer {
|
|
694
|
-
display: flex; gap:
|
|
695
|
-
border-top: 1px solid #f1f5f9;
|
|
652
|
+
display: flex; gap: 6px; padding-top: 8px;
|
|
653
|
+
border-top: 1px solid #f1f5f9; margin-top: 4px;
|
|
696
654
|
}
|
|
697
655
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
656
|
+
.md-mermaid {
|
|
657
|
+
margin: 8px 0;
|
|
658
|
+
padding: 10px;
|
|
659
|
+
background: #f8fafc;
|
|
660
|
+
border: 1px solid var(--border);
|
|
661
|
+
border-radius: var(--radius);
|
|
662
|
+
overflow-x: auto;
|
|
663
|
+
text-align: center;
|
|
664
|
+
}
|
|
665
|
+
.md-mermaid svg { max-width: 100%; height: auto; }
|
|
666
|
+
.md-mermaid-error {
|
|
667
|
+
margin: 8px 0;
|
|
668
|
+
padding: 8px 10px;
|
|
669
|
+
background: #fef2f2;
|
|
670
|
+
border: 1px solid #fecaca;
|
|
671
|
+
border-radius: var(--radius);
|
|
672
|
+
text-align: left;
|
|
673
|
+
}
|
|
674
|
+
.md-mermaid-error .md-err-label {
|
|
675
|
+
font-size: 10px;
|
|
676
|
+
font-weight: 700;
|
|
677
|
+
text-transform: uppercase;
|
|
678
|
+
letter-spacing: 0.5px;
|
|
679
|
+
color: #b91c1c;
|
|
680
|
+
margin-bottom: 6px;
|
|
681
|
+
}
|
|
682
|
+
.md-mermaid-error pre {
|
|
683
|
+
margin: 0;
|
|
684
|
+
font-size: 11px;
|
|
685
|
+
line-height: 1.4;
|
|
686
|
+
white-space: pre-wrap;
|
|
687
|
+
word-break: break-word;
|
|
688
|
+
color: var(--text);
|
|
689
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
704
690
|
}
|
|
705
|
-
.lane-add:hover { border-color: var(--planned); color: var(--planned); background: #eff6ff; }
|
|
706
691
|
|
|
707
|
-
/* ── Toast ── */
|
|
708
692
|
.toast {
|
|
709
693
|
position: fixed; bottom: 16px; right: 16px; background: #1e293b;
|
|
710
694
|
color: #fff; padding: 8px 14px; border-radius: 8px;
|
|
@@ -713,6 +697,15 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
713
697
|
pointer-events: none; z-index: 999;
|
|
714
698
|
}
|
|
715
699
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
700
|
+
|
|
701
|
+
@media (max-width: 1100px) {
|
|
702
|
+
:root { --paper-w: 320px; --rail-w: 180px; }
|
|
703
|
+
}
|
|
704
|
+
@media (max-width: 900px) {
|
|
705
|
+
.cockpit { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
|
|
706
|
+
#rail { max-height: 140px; border-right: none; border-bottom: 1px solid var(--border); flex-direction: row; flex-wrap: wrap; }
|
|
707
|
+
#paper { border-left: none; border-top: 1px solid var(--border); max-height: 40vh; }
|
|
708
|
+
}
|
|
716
709
|
</style>
|
|
717
710
|
</head>
|
|
718
711
|
<body>
|
|
@@ -721,8 +714,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
721
714
|
<h1>Donna Kanban</h1>
|
|
722
715
|
<span class="hstats" id="hstats">—</span>
|
|
723
716
|
<span class="hspacer"></span>
|
|
724
|
-
<button class="btn-new" id="btn-show-archived" onclick="toggleShowArchived()"
|
|
725
|
-
<button class="btn-new" id="btn-new-epic" onclick="toggleNewEpicForm()"
|
|
717
|
+
<button class="btn-new muted" id="btn-show-archived" onclick="toggleShowArchived()">Show archived</button>
|
|
718
|
+
<button class="btn-new secondary" id="btn-new-epic" onclick="toggleNewEpicForm()">+ New epic</button>
|
|
726
719
|
<button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ New task</button>
|
|
727
720
|
</header>
|
|
728
721
|
|
|
@@ -731,11 +724,11 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
731
724
|
<div class="ne-label">Epic title</div>
|
|
732
725
|
<input class="ne-input ne-title" id="nx-title" placeholder="Initiative name…" autocomplete="off">
|
|
733
726
|
</div>
|
|
734
|
-
<div class="ne-field" style="flex:1;min-width:
|
|
727
|
+
<div class="ne-field" style="flex:1;min-width:160px">
|
|
735
728
|
<div class="ne-label">Description</div>
|
|
736
729
|
<input class="ne-input" id="nx-description" placeholder="Context for agents…" autocomplete="off" style="width:100%">
|
|
737
730
|
</div>
|
|
738
|
-
<div class="ne-field" style="flex:1;min-width:
|
|
731
|
+
<div class="ne-field" style="flex:1;min-width:140px">
|
|
739
732
|
<div class="ne-label">Goals</div>
|
|
740
733
|
<input class="ne-input" id="nx-goals" placeholder="Outcome…" autocomplete="off" style="width:100%">
|
|
741
734
|
</div>
|
|
@@ -753,10 +746,10 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
753
746
|
<div class="ne-field">
|
|
754
747
|
<div class="ne-label">Column</div>
|
|
755
748
|
<select class="ne-input ne-col" id="ne-col">
|
|
756
|
-
<option value="icebox"
|
|
757
|
-
<option value="planned" selected
|
|
758
|
-
<option value="active"
|
|
759
|
-
<option value="done"
|
|
749
|
+
<option value="icebox">Icebox</option>
|
|
750
|
+
<option value="planned" selected>Planned</option>
|
|
751
|
+
<option value="active">NOW (active)</option>
|
|
752
|
+
<option value="done">Done</option>
|
|
760
753
|
</select>
|
|
761
754
|
</div>
|
|
762
755
|
<div class="ne-field">
|
|
@@ -770,32 +763,61 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
770
763
|
</div>
|
|
771
764
|
</div>
|
|
772
765
|
|
|
773
|
-
<
|
|
774
|
-
<
|
|
775
|
-
|
|
776
|
-
|
|
766
|
+
<main class="cockpit">
|
|
767
|
+
<aside id="rail"></aside>
|
|
768
|
+
|
|
769
|
+
<section class="board-center">
|
|
770
|
+
<div id="now">
|
|
771
|
+
<div class="now-head">
|
|
772
|
+
<span class="now-title">NOW</span>
|
|
773
|
+
<span class="now-count" id="now-count">0</span>
|
|
774
|
+
</div>
|
|
775
|
+
<div class="now-cards" id="now-cards"></div>
|
|
776
|
+
</div>
|
|
777
|
+
<div id="board-cols">
|
|
778
|
+
<div class="col" data-col="icebox">
|
|
779
|
+
<div class="col-head icebox">Icebox <span class="ch-count" id="cnt-icebox">0</span></div>
|
|
780
|
+
<div class="col-body" id="col-icebox"></div>
|
|
781
|
+
</div>
|
|
782
|
+
<div class="col" data-col="planned">
|
|
783
|
+
<div class="col-head planned">Planned <span class="ch-count" id="cnt-planned">0</span></div>
|
|
784
|
+
<div class="col-body" id="col-planned"></div>
|
|
785
|
+
</div>
|
|
786
|
+
<div class="col" data-col="done">
|
|
787
|
+
<div class="col-head done">Done <span class="ch-count" id="cnt-done">0</span></div>
|
|
788
|
+
<div class="col-body" id="col-done"></div>
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
</section>
|
|
792
|
+
|
|
793
|
+
<aside id="paper">
|
|
794
|
+
<div class="paper-empty">Select a card or epic</div>
|
|
795
|
+
</aside>
|
|
796
|
+
</main>
|
|
777
797
|
|
|
778
798
|
<div class="toast" id="toast"></div>
|
|
779
799
|
|
|
800
|
+
<script src="/vendor/fenced-text.js"></script>
|
|
801
|
+
<script src="/vendor/mermaid.min.js"></script>
|
|
780
802
|
<script>
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
{ id: "
|
|
784
|
-
{ id: "
|
|
785
|
-
{ id: "done", label: "
|
|
803
|
+
// Board columns exclude active — active lives in NOW only.
|
|
804
|
+
const BOARD_COLS = [
|
|
805
|
+
{ id: "icebox", label: "Icebox" },
|
|
806
|
+
{ id: "planned", label: "Planned" },
|
|
807
|
+
{ id: "done", label: "Done" }
|
|
786
808
|
];
|
|
809
|
+
// Move order: icebox ↔ planned ↔ active(NOW) ↔ done
|
|
810
|
+
const MOVE_ORDER = ["icebox", "planned", "active", "done"];
|
|
787
811
|
|
|
788
|
-
let allTasks
|
|
812
|
+
let allTasks = [];
|
|
789
813
|
let allEpicEntities = [];
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
let
|
|
794
|
-
let
|
|
795
|
-
let
|
|
796
|
-
let
|
|
797
|
-
let editingEpicId = null;
|
|
798
|
-
let dirtyEpicEdits = {}; // epicId -> draft
|
|
814
|
+
/** @type {null|string} null=all, "—"=no epic, "E001"=epic id */
|
|
815
|
+
let filterKey = null;
|
|
816
|
+
/** @type {null|{kind:'task'|'epic', id:string}} */
|
|
817
|
+
let paperTarget = null;
|
|
818
|
+
let paperEditing = false;
|
|
819
|
+
let dirtyEdits = {};
|
|
820
|
+
let dirtyEpicEdits = {};
|
|
799
821
|
let showArchived = false;
|
|
800
822
|
|
|
801
823
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
@@ -809,785 +831,801 @@ async function load() {
|
|
|
809
831
|
allTasks = await boardRes.json();
|
|
810
832
|
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
811
833
|
refreshEpicDatalist();
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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";
|
|
834
|
+
// Drop paper target if entity vanished
|
|
835
|
+
if (paperTarget) {
|
|
836
|
+
if (paperTarget.kind === "task" && !allTasks.find(t => t.id === paperTarget.id)) {
|
|
837
|
+
paperTarget = null;
|
|
838
|
+
paperEditing = false;
|
|
839
|
+
}
|
|
840
|
+
if (paperTarget && paperTarget.kind === "epic" && !allEpicEntities.find(e => e.id === paperTarget.id)) {
|
|
841
|
+
paperTarget = null;
|
|
842
|
+
paperEditing = false;
|
|
843
|
+
}
|
|
843
844
|
}
|
|
844
|
-
|
|
845
|
+
render();
|
|
845
846
|
}
|
|
846
847
|
|
|
847
848
|
function refreshEpicDatalist() {
|
|
848
849
|
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
|
-
|
|
855
|
-
function render() {
|
|
856
|
-
colCounts = {};
|
|
857
|
-
COL_DEFS.forEach(c => (colCounts[c.id] = 0));
|
|
858
|
-
allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
|
|
859
|
-
|
|
860
|
-
// Header stats
|
|
861
|
-
const total = allTasks.length;
|
|
862
|
-
const done = allTasks.filter(e => e.column === "done").length;
|
|
863
|
-
const epicN = allEpicEntities.length;
|
|
864
|
-
document.getElementById("hstats").textContent = `${done}/${total} done · ${epicN} epics`;
|
|
865
|
-
|
|
866
|
-
// Col headers
|
|
867
|
-
const hdr = document.getElementById("col-headers");
|
|
868
|
-
hdr.innerHTML = "";
|
|
869
|
-
COL_DEFS.forEach(c => {
|
|
870
|
-
const h = document.createElement("div");
|
|
871
|
-
h.className = `ch ${c.id}`;
|
|
872
|
-
h.innerHTML = `${c.label} <span class="ch-count">${colCounts[c.id]}</span>`;
|
|
873
|
-
hdr.appendChild(h);
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
// Swimlanes
|
|
877
|
-
const sl = document.getElementById("swimlanes");
|
|
878
|
-
sl.innerHTML = "";
|
|
879
|
-
groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
function groupTasks(tasks) {
|
|
883
|
-
const map = new Map();
|
|
884
|
-
tasks.forEach(t => {
|
|
885
|
-
const key = t.epic_id || "—";
|
|
886
|
-
if (!map.has(key)) map.set(key, []);
|
|
887
|
-
map.get(key).push(t);
|
|
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
|
-
|
|
894
|
-
return [...map.entries()]
|
|
895
|
-
.sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
|
|
896
|
-
.map(([key, laneTasks]) => {
|
|
897
|
-
const entity = allEpicEntities.find(e => e.id === key) || null;
|
|
898
|
-
const name = key === "—"
|
|
899
|
-
? "—"
|
|
900
|
-
: (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
|
|
901
|
-
return {
|
|
902
|
-
key,
|
|
903
|
-
name,
|
|
904
|
-
epicId: key === "—" ? null : key,
|
|
905
|
-
entity,
|
|
906
|
-
tasks: laneTasks
|
|
907
|
-
};
|
|
908
|
-
});
|
|
909
|
-
}
|
|
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
|
-
}
|
|
850
|
+
if (!list) return;
|
|
851
|
+
list.innerHTML = allEpicEntities.map(e =>
|
|
852
|
+
`<option value="${escHtml(e.id)}">${escHtml(e.title)}</option>`
|
|
853
|
+
).join("");
|
|
1091
854
|
}
|
|
1092
855
|
|
|
1093
|
-
|
|
856
|
+
function filteredTasks() {
|
|
857
|
+
if (filterKey === null) return allTasks.slice();
|
|
858
|
+
if (filterKey === "—") return allTasks.filter(t => !t.epic_id);
|
|
859
|
+
return allTasks.filter(t => t.epic_id === filterKey);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function render() {
|
|
863
|
+
const tasks = filteredTasks();
|
|
864
|
+
const total = allTasks.length;
|
|
865
|
+
const done = allTasks.filter(t => t.column === "done").length;
|
|
866
|
+
document.getElementById("hstats").textContent =
|
|
867
|
+
`${done}/${total} done · ${allEpicEntities.length} epics` +
|
|
868
|
+
(filterKey === null ? "" : filterKey === "—" ? " · filter: No epic" : ` · filter: ${filterKey}`);
|
|
869
|
+
|
|
870
|
+
renderRail();
|
|
871
|
+
renderNow(tasks);
|
|
872
|
+
renderBoardCols(tasks);
|
|
873
|
+
renderPaper();
|
|
874
|
+
queueMermaidRun(document.getElementById("paper"));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ── Rail ──────────────────────────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
function renderRail() {
|
|
880
|
+
const rail = document.getElementById("rail");
|
|
881
|
+
rail.innerHTML = "";
|
|
882
|
+
rail.appendChild(Object.assign(el("div", "rail-label"), { textContent: "Initiatives" }));
|
|
883
|
+
|
|
884
|
+
rail.appendChild(makeRailItem({
|
|
885
|
+
key: null,
|
|
886
|
+
title: "All",
|
|
887
|
+
idLabel: null,
|
|
888
|
+
count: allTasks.length,
|
|
889
|
+
status: null,
|
|
890
|
+
on: filterKey === null && !(paperTarget && paperTarget.kind === "epic")
|
|
891
|
+
}));
|
|
892
|
+
|
|
893
|
+
const orphanCount = allTasks.filter(t => !t.epic_id).length;
|
|
894
|
+
rail.appendChild(makeRailItem({
|
|
895
|
+
key: "—",
|
|
896
|
+
title: "No epic",
|
|
897
|
+
idLabel: null,
|
|
898
|
+
count: orphanCount,
|
|
899
|
+
status: null,
|
|
900
|
+
on: filterKey === "—"
|
|
901
|
+
}));
|
|
1094
902
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
903
|
+
allEpicEntities
|
|
904
|
+
.slice()
|
|
905
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
906
|
+
.forEach(entity => {
|
|
907
|
+
const p = entity.progress || {};
|
|
908
|
+
const cnt = p.tasks_total != null
|
|
909
|
+
? p.tasks_total
|
|
910
|
+
: allTasks.filter(t => t.epic_id === entity.id).length;
|
|
911
|
+
const selected = paperTarget && paperTarget.kind === "epic" && paperTarget.id === entity.id;
|
|
912
|
+
rail.appendChild(makeRailItem({
|
|
913
|
+
key: entity.id,
|
|
914
|
+
title: entity.title || entity.id,
|
|
915
|
+
idLabel: entity.id,
|
|
916
|
+
count: cnt,
|
|
917
|
+
status: entity.status || null,
|
|
918
|
+
on: selected || filterKey === entity.id,
|
|
919
|
+
entity
|
|
920
|
+
}));
|
|
921
|
+
});
|
|
1098
922
|
}
|
|
1099
923
|
|
|
1100
|
-
function
|
|
924
|
+
function makeRailItem({ key, title, idLabel, count, status, on, entity }) {
|
|
1101
925
|
const btn = document.createElement("button");
|
|
1102
926
|
btn.type = "button";
|
|
1103
|
-
btn.className = `
|
|
1104
|
-
btn.innerHTML =
|
|
1105
|
-
|
|
927
|
+
btn.className = `rail-item${on ? " on" : ""}`;
|
|
928
|
+
btn.innerHTML = `
|
|
929
|
+
<span class="ri-title">${escHtml(title)}</span>
|
|
930
|
+
${idLabel ? `<span class="ri-id">${escHtml(idLabel)}</span>` : ""}
|
|
931
|
+
${status ? `<span class="ri-status ${escHtml(status)}">${escHtml(status)}</span>` : ""}
|
|
932
|
+
<span class="ri-cnt">${count}</span>
|
|
933
|
+
`;
|
|
934
|
+
btn.onclick = () => {
|
|
935
|
+
if (key === null) {
|
|
936
|
+
filterKey = null;
|
|
937
|
+
// keep paper if open
|
|
938
|
+
} else if (key === "—") {
|
|
939
|
+
filterKey = "—";
|
|
940
|
+
if (paperTarget && paperTarget.kind === "epic") {
|
|
941
|
+
paperTarget = null;
|
|
942
|
+
paperEditing = false;
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
filterKey = key;
|
|
946
|
+
openPaper("epic", key);
|
|
947
|
+
return; // openPaper → render
|
|
948
|
+
}
|
|
949
|
+
render();
|
|
950
|
+
};
|
|
1106
951
|
return btn;
|
|
1107
952
|
}
|
|
1108
953
|
|
|
1109
|
-
|
|
1110
|
-
const infoOpen = entity ? !collapsedInfo.has(key) : false;
|
|
1111
|
-
const tasksOpen = !collapsedTasks.has(key);
|
|
1112
|
-
const isEditingEpic = epicId && editingEpicId === epicId;
|
|
1113
|
-
|
|
1114
|
-
const wrap = el("div", "swimlane");
|
|
1115
|
-
|
|
1116
|
-
const hdr = el("div", "sl-header");
|
|
1117
|
-
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
1118
|
-
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
1119
|
-
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
1120
|
-
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
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>` : ""}
|
|
1131
|
-
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
1132
|
-
${pct !== null ? `<span class="sl-progress">${pct}% subtasks</span>` : ""}
|
|
1133
|
-
`;
|
|
1134
|
-
hdr.appendChild(main);
|
|
1135
|
-
|
|
1136
|
-
const toggles = el("div", "sl-toggles");
|
|
954
|
+
// ── NOW + columns ─────────────────────────────────────────────────────────────
|
|
1137
955
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
956
|
+
function renderNow(tasks) {
|
|
957
|
+
const active = tasks.filter(t => t.column === "active");
|
|
958
|
+
document.getElementById("now-count").textContent = String(active.length);
|
|
959
|
+
const wrap = document.getElementById("now-cards");
|
|
960
|
+
wrap.innerHTML = "";
|
|
961
|
+
if (active.length === 0) {
|
|
962
|
+
wrap.appendChild(Object.assign(el("div", "now-empty"), {
|
|
963
|
+
textContent: "Nothing in NOW — move a planned card here →"
|
|
1142
964
|
}));
|
|
965
|
+
} else {
|
|
966
|
+
active.forEach(t => wrap.appendChild(renderCard(t, "active")));
|
|
1143
967
|
}
|
|
968
|
+
// lane-add into NOW
|
|
969
|
+
const addBtn = document.createElement("button");
|
|
970
|
+
addBtn.className = "lane-add";
|
|
971
|
+
addBtn.textContent = "+ Add to NOW";
|
|
972
|
+
addBtn.style.width = "240px";
|
|
973
|
+
addBtn.onclick = e => {
|
|
974
|
+
e.stopPropagation();
|
|
975
|
+
showLaneAdd(wrap, "active", currentEpicRef(), addBtn);
|
|
976
|
+
};
|
|
977
|
+
wrap.appendChild(addBtn);
|
|
978
|
+
}
|
|
1144
979
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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);
|
|
1191
|
-
wrap.appendChild(hdr);
|
|
1192
|
-
|
|
1193
|
-
if (entity && (infoOpen || isEditingEpic)) {
|
|
1194
|
-
wrap.appendChild(
|
|
1195
|
-
isEditingEpic ? renderEpicDetailEdit(entity) : renderEpicDetailView(entity)
|
|
1196
|
-
);
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const body = el("div", `sl-body${tasksOpen ? "" : " hidden"}`);
|
|
1200
|
-
COL_DEFS.forEach(col => {
|
|
1201
|
-
const lane = el("div", `lane col-${col.id}`);
|
|
1202
|
-
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
980
|
+
function renderBoardCols(tasks) {
|
|
981
|
+
BOARD_COLS.forEach(col => {
|
|
982
|
+
const list = tasks.filter(t => t.column === col.id);
|
|
983
|
+
document.getElementById("cnt-" + col.id).textContent = String(list.length);
|
|
984
|
+
const body = document.getElementById("col-" + col.id);
|
|
985
|
+
body.innerHTML = "";
|
|
986
|
+
list.forEach(t => body.appendChild(renderCard(t, col.id)));
|
|
1203
987
|
if (col.id !== "done") {
|
|
1204
988
|
const addBtn = document.createElement("button");
|
|
1205
989
|
addBtn.className = "lane-add";
|
|
1206
990
|
addBtn.textContent = "+ Add here";
|
|
1207
|
-
addBtn.onclick = e => {
|
|
1208
|
-
|
|
991
|
+
addBtn.onclick = e => {
|
|
992
|
+
e.stopPropagation();
|
|
993
|
+
showLaneAdd(body, col.id, currentEpicRef(), addBtn);
|
|
994
|
+
};
|
|
995
|
+
body.appendChild(addBtn);
|
|
1209
996
|
}
|
|
1210
|
-
body.appendChild(lane);
|
|
1211
997
|
});
|
|
1212
|
-
wrap.appendChild(body);
|
|
1213
|
-
return wrap;
|
|
1214
998
|
}
|
|
1215
999
|
|
|
1216
|
-
|
|
1000
|
+
function currentEpicRef() {
|
|
1001
|
+
if (filterKey && filterKey !== "—") return filterKey;
|
|
1002
|
+
return "";
|
|
1003
|
+
}
|
|
1217
1004
|
|
|
1218
1005
|
function getNeighborCols(colId) {
|
|
1219
|
-
const idx =
|
|
1006
|
+
const idx = MOVE_ORDER.indexOf(colId);
|
|
1220
1007
|
return {
|
|
1221
|
-
prev: idx > 0
|
|
1222
|
-
next: idx <
|
|
1008
|
+
prev: idx > 0 ? MOVE_ORDER[idx - 1] : null,
|
|
1009
|
+
next: idx >= 0 && idx < MOVE_ORDER.length - 1 ? MOVE_ORDER[idx + 1] : null
|
|
1223
1010
|
};
|
|
1224
1011
|
}
|
|
1225
1012
|
|
|
1226
|
-
async function moveCard(
|
|
1227
|
-
await fetch(`/api/epics/${enc(
|
|
1013
|
+
async function moveCard(taskId, targetColId) {
|
|
1014
|
+
await fetch(`/api/epics/${enc(taskId)}/move`, {
|
|
1228
1015
|
method: "PATCH",
|
|
1229
1016
|
headers: { "Content-Type": "application/json" },
|
|
1230
|
-
body: JSON.stringify({ column: targetColId })
|
|
1017
|
+
body: JSON.stringify({ column: targetColId })
|
|
1231
1018
|
});
|
|
1232
|
-
expanded.delete(epicId);
|
|
1233
1019
|
await load();
|
|
1234
1020
|
}
|
|
1235
1021
|
|
|
1236
|
-
function renderCard(
|
|
1237
|
-
const
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
card.dataset.id = epic.id;
|
|
1022
|
+
function renderCard(task, colId) {
|
|
1023
|
+
const selected = paperTarget && paperTarget.kind === "task" && paperTarget.id === task.id;
|
|
1024
|
+
const card = el("div", `card col-${colId}${selected ? " selected" : ""}`);
|
|
1025
|
+
card.dataset.id = task.id;
|
|
1241
1026
|
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
const
|
|
1245
|
-
const
|
|
1246
|
-
const
|
|
1027
|
+
const subtasks = task.subtasks || [];
|
|
1028
|
+
const totalT = subtasks.length;
|
|
1029
|
+
const doneT = subtasks.filter(t => t.done).length;
|
|
1030
|
+
const pct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
|
|
1031
|
+
const shortId = (String(task.id).match(/^PI-\d+/) || [task.id])[0];
|
|
1032
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1247
1033
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1034
|
+
const top = el("div", "card-top");
|
|
1035
|
+
const idBadge = el("span", "card-id");
|
|
1036
|
+
idBadge.textContent = shortId;
|
|
1037
|
+
top.appendChild(idBadge);
|
|
1038
|
+
card.appendChild(top);
|
|
1039
|
+
|
|
1040
|
+
const titleEl = el("div", "card-title");
|
|
1041
|
+
titleEl.textContent = displayTitle;
|
|
1042
|
+
card.appendChild(titleEl);
|
|
1043
|
+
|
|
1044
|
+
if (totalT > 0 && colId !== "icebox") {
|
|
1045
|
+
const pr = el("div", "prog-row");
|
|
1046
|
+
pr.innerHTML = `
|
|
1047
|
+
<div class="prog-bar"><div class="prog-fill ${colId}" style="width:${pct}%"></div></div>
|
|
1048
|
+
<span class="prog-txt">${doneT}/${totalT}</span>
|
|
1049
|
+
`;
|
|
1050
|
+
card.appendChild(pr);
|
|
1051
|
+
} else if (totalT > 0 && colId === "icebox") {
|
|
1052
|
+
const badge = el("span", "prog-txt");
|
|
1053
|
+
badge.style.cssText = "display:inline-block;margin-top:5px;background:rgba(100,116,139,0.12);border-radius:4px;padding:1px 6px;";
|
|
1054
|
+
badge.textContent = `${totalT} subtasks`;
|
|
1055
|
+
card.appendChild(badge);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const actions = el("div", "card-actions");
|
|
1059
|
+
const { prev, next } = getNeighborCols(colId);
|
|
1060
|
+
if (prev) {
|
|
1061
|
+
const lb = document.createElement("button");
|
|
1062
|
+
lb.className = "cbtn arr";
|
|
1063
|
+
lb.textContent = "←";
|
|
1064
|
+
lb.title = `→ ${prev}`;
|
|
1065
|
+
lb.onclick = async e => { e.stopPropagation(); await moveCard(task.id, prev); };
|
|
1066
|
+
actions.appendChild(lb);
|
|
1067
|
+
}
|
|
1068
|
+
if (next) {
|
|
1069
|
+
const rb = document.createElement("button");
|
|
1070
|
+
rb.className = "cbtn arr";
|
|
1071
|
+
rb.textContent = "→";
|
|
1072
|
+
rb.title = `→ ${next}`;
|
|
1073
|
+
rb.onclick = async e => { e.stopPropagation(); await moveCard(task.id, next); };
|
|
1074
|
+
actions.appendChild(rb);
|
|
1075
|
+
}
|
|
1076
|
+
const delBtn = document.createElement("button");
|
|
1077
|
+
delBtn.className = "cbtn";
|
|
1078
|
+
delBtn.textContent = "✕";
|
|
1079
|
+
delBtn.title = "Delete task";
|
|
1080
|
+
delBtn.onclick = e => {
|
|
1081
|
+
e.stopPropagation();
|
|
1082
|
+
deleteTaskCard(task.id, displayTitle);
|
|
1083
|
+
};
|
|
1084
|
+
actions.appendChild(delBtn);
|
|
1085
|
+
card.appendChild(actions);
|
|
1086
|
+
|
|
1087
|
+
card.onclick = e => {
|
|
1088
|
+
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT") return;
|
|
1089
|
+
openPaper("task", task.id);
|
|
1090
|
+
};
|
|
1091
|
+
return card;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// ── Paper ─────────────────────────────────────────────────────────────────────
|
|
1095
|
+
|
|
1096
|
+
function openPaper(kind, id) {
|
|
1097
|
+
if (paperTarget && (paperTarget.kind !== kind || paperTarget.id !== id)) {
|
|
1098
|
+
paperEditing = false;
|
|
1099
|
+
dirtyEdits = {};
|
|
1100
|
+
dirtyEpicEdits = {};
|
|
1101
|
+
}
|
|
1102
|
+
paperTarget = { kind, id };
|
|
1103
|
+
render();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function closePaper() {
|
|
1107
|
+
paperTarget = null;
|
|
1108
|
+
paperEditing = false;
|
|
1109
|
+
dirtyEdits = {};
|
|
1110
|
+
dirtyEpicEdits = {};
|
|
1111
|
+
render();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function renderPaper() {
|
|
1115
|
+
const paper = document.getElementById("paper");
|
|
1116
|
+
paper.innerHTML = "";
|
|
1117
|
+
|
|
1118
|
+
if (!paperTarget) {
|
|
1119
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), {
|
|
1120
|
+
textContent: "Select a card or epic to open the paper"
|
|
1121
|
+
}));
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (paperTarget.kind === "task") {
|
|
1126
|
+
const task = allTasks.find(t => t.id === paperTarget.id);
|
|
1127
|
+
if (!task) {
|
|
1128
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), { textContent: "Task not found" }));
|
|
1129
|
+
return;
|
|
1277
1130
|
}
|
|
1131
|
+
if (paperEditing) renderTaskPaperEdit(paper, task);
|
|
1132
|
+
else renderTaskPaperView(paper, task);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1278
1135
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1136
|
+
const entity = allEpicEntities.find(e => e.id === paperTarget.id);
|
|
1137
|
+
if (!entity) {
|
|
1138
|
+
paper.appendChild(Object.assign(el("div", "paper-empty"), { textContent: "Epic not found" }));
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (paperEditing) renderEpicPaperEdit(paper, entity);
|
|
1142
|
+
else renderEpicPaperView(paper, entity);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function paperHead(kindLabel, badges, title, actions) {
|
|
1146
|
+
const head = el("div", "paper-head");
|
|
1147
|
+
const kicker = el("div", "paper-kicker");
|
|
1148
|
+
kicker.appendChild(Object.assign(el("span", "paper-kind"), { textContent: kindLabel }));
|
|
1149
|
+
(badges || []).forEach(b => {
|
|
1150
|
+
const sp = el("span", `paper-badge${b.cls ? " " + b.cls : ""}`);
|
|
1151
|
+
sp.textContent = b.text;
|
|
1152
|
+
kicker.appendChild(sp);
|
|
1153
|
+
});
|
|
1154
|
+
head.appendChild(kicker);
|
|
1155
|
+
head.appendChild(Object.assign(el("div", "paper-title"), { textContent: title }));
|
|
1156
|
+
if (actions && actions.length) {
|
|
1157
|
+
const row = el("div", "paper-actions");
|
|
1158
|
+
actions.forEach(a => row.appendChild(a));
|
|
1159
|
+
head.appendChild(row);
|
|
1160
|
+
}
|
|
1161
|
+
return head;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function makePaperBtn(label, cls, onClick) {
|
|
1165
|
+
const b = document.createElement("button");
|
|
1166
|
+
b.type = "button";
|
|
1167
|
+
b.className = cls || "btn-sm btn-cancel";
|
|
1168
|
+
b.textContent = label;
|
|
1169
|
+
b.onclick = onClick;
|
|
1170
|
+
return b;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function renderTaskPaperView(paper, task) {
|
|
1174
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1175
|
+
const badges = [
|
|
1176
|
+
{ text: task.id, cls: "" },
|
|
1177
|
+
{ text: task.column === "active" ? "NOW" : task.column, cls: "col-" + task.column }
|
|
1178
|
+
];
|
|
1179
|
+
if (task.epic_id) badges.push({ text: task.epic_id, cls: "" });
|
|
1180
|
+
if (task.plan && task.plan.status) badges.push({ text: "plan:" + task.plan.status, cls: "" });
|
|
1181
|
+
|
|
1182
|
+
const actions = [
|
|
1183
|
+
makePaperBtn("Edit", "btn-sm btn-confirm", () => { paperEditing = true; render(); }),
|
|
1184
|
+
makePaperBtn("Close", "btn-sm btn-cancel", () => closePaper())
|
|
1185
|
+
];
|
|
1186
|
+
paper.appendChild(paperHead("Task", badges, displayTitle, actions));
|
|
1187
|
+
|
|
1188
|
+
const body = el("div", "paper-body");
|
|
1189
|
+
|
|
1190
|
+
if (task.description) body.appendChild(renderDetailSection("What", task.description));
|
|
1191
|
+
if (task.rationale) body.appendChild(renderDetailSection("Rationale", task.rationale));
|
|
1192
|
+
if (task.outcome) body.appendChild(renderDetailSection("Outcome", task.outcome));
|
|
1193
|
+
if (task.specs) body.appendChild(renderDetailSection("Specs", task.specs));
|
|
1194
|
+
|
|
1195
|
+
if ((task.in_scope && task.in_scope.length) || (task.out_of_scope && task.out_of_scope.length)) {
|
|
1196
|
+
const pair = el("div", "scope-pair");
|
|
1197
|
+
if (task.in_scope && task.in_scope.length) {
|
|
1198
|
+
pair.appendChild(renderDetailListSection("In Scope", task.in_scope));
|
|
1291
1199
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const rb = document.createElement("button");
|
|
1295
|
-
rb.className = "cbtn arr";
|
|
1296
|
-
rb.textContent = "→";
|
|
1297
|
-
rb.title = `→ ${next.label}`;
|
|
1298
|
-
rb.onclick = async e => { e.stopPropagation(); await moveCard(epic.id, next.id); };
|
|
1299
|
-
actions.appendChild(rb);
|
|
1200
|
+
if (task.out_of_scope && task.out_of_scope.length) {
|
|
1201
|
+
pair.appendChild(renderDetailListSection("Out of Scope", task.out_of_scope, "out-of-scope"));
|
|
1300
1202
|
}
|
|
1203
|
+
body.appendChild(pair);
|
|
1204
|
+
}
|
|
1301
1205
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
// Edit button
|
|
1310
|
-
const editBtn = document.createElement("button");
|
|
1311
|
-
editBtn.className = "cbtn edit-btn";
|
|
1312
|
-
editBtn.textContent = "✎";
|
|
1313
|
-
editBtn.title = "Edit";
|
|
1314
|
-
editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
|
|
1315
|
-
actions.appendChild(editBtn);
|
|
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
|
-
|
|
1327
|
-
card.appendChild(actions);
|
|
1328
|
-
|
|
1329
|
-
// Details + subtasks (expanded, view mode)
|
|
1330
|
-
if (isExpanded) {
|
|
1331
|
-
appendDetailSections(card, epic);
|
|
1332
|
-
|
|
1333
|
-
if (totalT > 0) {
|
|
1334
|
-
const lbl = el("div", "subtask-label");
|
|
1335
|
-
lbl.textContent = "Subtasks";
|
|
1336
|
-
card.appendChild(lbl);
|
|
1337
|
-
|
|
1338
|
-
const list = el("div", "tasks-list");
|
|
1339
|
-
epic.subtasks.forEach((task, idx) => {
|
|
1340
|
-
const row = el("div", `task-row${task.done ? " done" : ""}`);
|
|
1341
|
-
const cbId = `v-${epic.id}-${idx}`;
|
|
1342
|
-
row.innerHTML = `
|
|
1343
|
-
<input type="checkbox" id="${cbId}" ${task.done ? "checked" : ""}>
|
|
1344
|
-
<div class="task-copy">
|
|
1345
|
-
<label for="${cbId}">${escHtml(task.text)}</label>
|
|
1346
|
-
${task.description ? `<div class="task-desc">${escHtml(task.description)}</div>` : ""}
|
|
1347
|
-
</div>
|
|
1348
|
-
`;
|
|
1349
|
-
row.querySelector("input").onchange = async function() {
|
|
1350
|
-
this.disabled = true;
|
|
1351
|
-
await fetch(`/api/epics/${enc(epic.id)}/tasks/${idx}`, { method: "PATCH" });
|
|
1352
|
-
await load();
|
|
1353
|
-
};
|
|
1354
|
-
list.appendChild(row);
|
|
1355
|
-
});
|
|
1356
|
-
card.appendChild(list);
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1206
|
+
if (task.acceptance_criteria && task.acceptance_criteria.length) {
|
|
1207
|
+
body.appendChild(renderDetailListSection("Acceptance Criteria", task.acceptance_criteria));
|
|
1208
|
+
}
|
|
1209
|
+
if (task.test_cases && task.test_cases.length) {
|
|
1210
|
+
body.appendChild(renderDetailListSection("Test Cases", task.test_cases));
|
|
1211
|
+
}
|
|
1212
|
+
if (task.notes) body.appendChild(renderDetailSection("Notes", task.notes));
|
|
1359
1213
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1214
|
+
const subtasks = task.subtasks || [];
|
|
1215
|
+
if (subtasks.length > 0) {
|
|
1216
|
+
const wrap = el("div", "detail-section");
|
|
1217
|
+
wrap.appendChild(Object.assign(el("div", "subtask-label"), {
|
|
1218
|
+
textContent: `Steps / subtasks (${subtasks.filter(s => s.done).length}/${subtasks.length})`
|
|
1219
|
+
}));
|
|
1220
|
+
const list = el("div", "tasks-list");
|
|
1221
|
+
subtasks.forEach((st, idx) => {
|
|
1222
|
+
const row = el("div", `task-row${st.done ? " done" : ""}`);
|
|
1223
|
+
const cbId = `p-${task.id}-${idx}`;
|
|
1224
|
+
row.innerHTML = `
|
|
1225
|
+
<input type="checkbox" id="${cbId}" ${st.done ? "checked" : ""}>
|
|
1226
|
+
<div class="task-copy">
|
|
1227
|
+
<label for="${cbId}">${escHtml(st.text)}</label>
|
|
1228
|
+
${st.description ? `<div class="task-desc">${escHtml(st.description)}</div>` : ""}
|
|
1229
|
+
</div>
|
|
1230
|
+
`;
|
|
1231
|
+
row.querySelector("input").onchange = async function() {
|
|
1232
|
+
this.disabled = true;
|
|
1233
|
+
await fetch(`/api/epics/${enc(task.id)}/tasks/${idx}`, { method: "PATCH" });
|
|
1234
|
+
await load();
|
|
1235
|
+
};
|
|
1236
|
+
list.appendChild(row);
|
|
1237
|
+
});
|
|
1238
|
+
wrap.appendChild(list);
|
|
1239
|
+
body.appendChild(wrap);
|
|
1364
1240
|
}
|
|
1365
1241
|
|
|
1366
|
-
|
|
1242
|
+
// ADR (read-only)
|
|
1243
|
+
const adrs = Array.isArray(task.adr) ? task.adr : [];
|
|
1244
|
+
if (adrs.length > 0) {
|
|
1245
|
+
const wrap = el("div", "detail-section");
|
|
1246
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "ADR" }));
|
|
1247
|
+
adrs.forEach(a => {
|
|
1248
|
+
const card = el("div", "adr-card");
|
|
1249
|
+
card.innerHTML = `
|
|
1250
|
+
<div class="adr-decision">${escHtml(a.decision || "")}</div>
|
|
1251
|
+
<div class="adr-why">${escHtml(a.why || "")}</div>
|
|
1252
|
+
<div class="adr-meta">${escHtml(a.id || "")}${a.created ? " · " + escHtml(a.created) : ""}</div>
|
|
1253
|
+
`;
|
|
1254
|
+
wrap.appendChild(card);
|
|
1255
|
+
});
|
|
1256
|
+
body.appendChild(wrap);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Evidence (read-only)
|
|
1260
|
+
const evidence = Array.isArray(task.evidence) ? task.evidence : [];
|
|
1261
|
+
if (evidence.length > 0) {
|
|
1262
|
+
const wrap = el("div", "detail-section");
|
|
1263
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "Evidence" }));
|
|
1264
|
+
evidence.forEach(ev => {
|
|
1265
|
+
const card = el("div", "evidence-card");
|
|
1266
|
+
const cmd = el("div", "ev-cmd");
|
|
1267
|
+
cmd.textContent = ev.test_command || "(no command)";
|
|
1268
|
+
card.appendChild(cmd);
|
|
1269
|
+
const meta = el("div", "ev-meta");
|
|
1270
|
+
meta.textContent = `exit ${ev.exit_code == null ? "?" : ev.exit_code}${ev.created ? " · " + ev.created : ""}`;
|
|
1271
|
+
card.appendChild(meta);
|
|
1272
|
+
if (ev.diff) {
|
|
1273
|
+
const diff = el("div", "ev-diff");
|
|
1274
|
+
diff.textContent = ev.diff;
|
|
1275
|
+
card.appendChild(diff);
|
|
1276
|
+
}
|
|
1277
|
+
wrap.appendChild(card);
|
|
1278
|
+
});
|
|
1279
|
+
body.appendChild(wrap);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
paper.appendChild(body);
|
|
1367
1283
|
}
|
|
1368
1284
|
|
|
1369
|
-
function
|
|
1370
|
-
const displayTitle =
|
|
1371
|
-
const dirty = dirtyEdits[
|
|
1285
|
+
function renderTaskPaperEdit(paper, task) {
|
|
1286
|
+
const displayTitle = String(task.title || "").replace(/^[\w.-]+:\s*/, "");
|
|
1287
|
+
const dirty = dirtyEdits[task.id] || {
|
|
1372
1288
|
title: displayTitle,
|
|
1373
|
-
description:
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1289
|
+
description: task.description || "",
|
|
1290
|
+
rationale: task.rationale || "",
|
|
1291
|
+
outcome: task.outcome || "",
|
|
1292
|
+
specs: task.specs || "",
|
|
1293
|
+
in_scope: (task.in_scope || []).join("\n"),
|
|
1294
|
+
out_of_scope: (task.out_of_scope || []).join("\n"),
|
|
1295
|
+
acceptance_criteria: (task.acceptance_criteria || []).join("\n"),
|
|
1296
|
+
test_cases: (task.test_cases || []).join("\n"),
|
|
1297
|
+
notes: task.notes || "",
|
|
1298
|
+
tasks: (task.subtasks || []).map(t => ({ ...t }))
|
|
1381
1299
|
};
|
|
1382
|
-
dirtyEdits[
|
|
1300
|
+
dirtyEdits[task.id] = dirty;
|
|
1383
1301
|
|
|
1384
|
-
const
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1302
|
+
const badges = [
|
|
1303
|
+
{ text: task.id, cls: "" },
|
|
1304
|
+
{ text: task.column === "active" ? "NOW" : task.column, cls: "col-" + task.column }
|
|
1305
|
+
];
|
|
1306
|
+
paper.appendChild(paperHead("Task · edit", badges, "Editing", []));
|
|
1307
|
+
|
|
1308
|
+
const body = el("div", "paper-body");
|
|
1389
1309
|
|
|
1390
|
-
// Title input
|
|
1391
1310
|
const titleInput = document.createElement("input");
|
|
1392
1311
|
titleInput.className = "edit-title-input";
|
|
1393
1312
|
titleInput.value = dirty.title;
|
|
1394
|
-
titleInput.oninput = () => { dirty.title = titleInput.value; dirtyEdits[
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
}, "
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
}, "What this task covers (one item per line)"));
|
|
1410
|
-
meta.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, value => {
|
|
1411
|
-
dirty.out_of_scope = value;
|
|
1412
|
-
dirtyEdits[epic.id] = dirty;
|
|
1413
|
-
}, "What is explicitly not included (one item per line)", "out-of-scope"));
|
|
1414
|
-
meta.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, value => {
|
|
1415
|
-
dirty.acceptance_criteria = value;
|
|
1416
|
-
dirtyEdits[epic.id] = dirty;
|
|
1417
|
-
}, "One condition per line"));
|
|
1418
|
-
meta.appendChild(renderEditBlock("Test Cases", dirty.test_cases, value => {
|
|
1419
|
-
dirty.test_cases = value;
|
|
1420
|
-
dirtyEdits[epic.id] = dirty;
|
|
1421
|
-
}, "One scenario per line"));
|
|
1422
|
-
meta.appendChild(renderEditBlock("Notes", dirty.notes, value => {
|
|
1423
|
-
dirty.notes = value;
|
|
1424
|
-
dirtyEdits[epic.id] = dirty;
|
|
1425
|
-
}, "Optional notes"));
|
|
1426
|
-
card.appendChild(meta);
|
|
1427
|
-
|
|
1428
|
-
// Subtasks section
|
|
1429
|
-
const editTasks = el("div", "edit-tasks");
|
|
1430
|
-
const subtaskLbl = el("div", "subtask-label");
|
|
1431
|
-
subtaskLbl.textContent = "Subtasks";
|
|
1432
|
-
editTasks.appendChild(subtaskLbl);
|
|
1433
|
-
|
|
1313
|
+
titleInput.oninput = () => { dirty.title = titleInput.value; dirtyEdits[task.id] = dirty; };
|
|
1314
|
+
body.appendChild(titleInput);
|
|
1315
|
+
|
|
1316
|
+
body.appendChild(renderEditBlock("What", dirty.description, v => { dirty.description = v; dirtyEdits[task.id] = dirty; }, "What we are doing…"));
|
|
1317
|
+
body.appendChild(renderEditBlock("Rationale", dirty.rationale, v => { dirty.rationale = v; dirtyEdits[task.id] = dirty; }, "Why this approach…"));
|
|
1318
|
+
body.appendChild(renderEditBlock("Outcome", dirty.outcome, v => { dirty.outcome = v; dirtyEdits[task.id] = dirty; }, "Intended effect…"));
|
|
1319
|
+
body.appendChild(renderEditBlock("Specs", dirty.specs, v => { dirty.specs = v; dirtyEdits[task.id] = dirty; }, "APIs, constraints…"));
|
|
1320
|
+
body.appendChild(renderEditBlock("In Scope", dirty.in_scope, v => { dirty.in_scope = v; dirtyEdits[task.id] = dirty; }, "One item per line"));
|
|
1321
|
+
body.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, v => { dirty.out_of_scope = v; dirtyEdits[task.id] = dirty; }, "One item per line", "out-of-scope"));
|
|
1322
|
+
body.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, v => { dirty.acceptance_criteria = v; dirtyEdits[task.id] = dirty; }, "One condition per line"));
|
|
1323
|
+
body.appendChild(renderEditBlock("Test Cases", dirty.test_cases, v => { dirty.test_cases = v; dirtyEdits[task.id] = dirty; }, "One scenario per line"));
|
|
1324
|
+
body.appendChild(renderEditBlock("Notes", dirty.notes, v => { dirty.notes = v; dirtyEdits[task.id] = dirty; }, "Optional notes"));
|
|
1325
|
+
|
|
1326
|
+
const editTasks = el("div", "detail-section");
|
|
1327
|
+
editTasks.appendChild(Object.assign(el("div", "subtask-label"), { textContent: "Steps / subtasks" }));
|
|
1434
1328
|
const taskRows = el("div");
|
|
1435
|
-
|
|
1436
1329
|
const refreshRows = () => {
|
|
1437
1330
|
taskRows.innerHTML = "";
|
|
1438
|
-
dirty.tasks.forEach((
|
|
1331
|
+
dirty.tasks.forEach((st, idx) => {
|
|
1439
1332
|
const row = el("div", "edit-task-row");
|
|
1440
1333
|
const cb = document.createElement("input");
|
|
1441
1334
|
cb.type = "checkbox";
|
|
1442
|
-
cb.checked =
|
|
1443
|
-
cb.onchange = () => { dirty.tasks[idx].done = cb.checked; dirtyEdits[
|
|
1444
|
-
|
|
1335
|
+
cb.checked = st.done;
|
|
1336
|
+
cb.onchange = () => { dirty.tasks[idx].done = cb.checked; dirtyEdits[task.id] = dirty; };
|
|
1445
1337
|
const fields = el("div", "edit-task-fields");
|
|
1446
1338
|
const inp = document.createElement("input");
|
|
1447
1339
|
inp.className = "edit-task-input";
|
|
1448
|
-
inp.value =
|
|
1449
|
-
inp.oninput = () => { dirty.tasks[idx].text = inp.value; dirtyEdits[
|
|
1450
|
-
inp.onkeydown = e => {
|
|
1451
|
-
if (e.key === "Enter") { e.preventDefault(); addSubtaskInput.focus(); }
|
|
1452
|
-
if (e.key === "Backspace" && inp.value === "") {
|
|
1453
|
-
e.preventDefault();
|
|
1454
|
-
dirty.tasks.splice(idx, 1);
|
|
1455
|
-
dirtyEdits[epic.id] = dirty;
|
|
1456
|
-
refreshRows();
|
|
1457
|
-
}
|
|
1458
|
-
};
|
|
1459
|
-
|
|
1340
|
+
inp.value = st.text;
|
|
1341
|
+
inp.oninput = () => { dirty.tasks[idx].text = inp.value; dirtyEdits[task.id] = dirty; };
|
|
1460
1342
|
const desc = document.createElement("textarea");
|
|
1461
1343
|
desc.className = "edit-task-desc";
|
|
1462
|
-
desc.placeholder = "Subtask description
|
|
1463
|
-
desc.value =
|
|
1464
|
-
desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
delBtn.title = "Remove subtask";
|
|
1470
|
-
delBtn.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[epic.id] = dirty; refreshRows(); };
|
|
1471
|
-
|
|
1344
|
+
desc.placeholder = "Subtask description…";
|
|
1345
|
+
desc.value = st.description || "";
|
|
1346
|
+
desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[task.id] = dirty; };
|
|
1347
|
+
const del = document.createElement("button");
|
|
1348
|
+
del.className = "del-task";
|
|
1349
|
+
del.textContent = "×";
|
|
1350
|
+
del.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[task.id] = dirty; refreshRows(); };
|
|
1472
1351
|
fields.appendChild(inp);
|
|
1473
1352
|
fields.appendChild(desc);
|
|
1474
|
-
row.appendChild(cb);
|
|
1353
|
+
row.appendChild(cb);
|
|
1354
|
+
row.appendChild(fields);
|
|
1355
|
+
row.appendChild(del);
|
|
1475
1356
|
taskRows.appendChild(row);
|
|
1476
1357
|
});
|
|
1477
1358
|
};
|
|
1478
1359
|
refreshRows();
|
|
1479
1360
|
editTasks.appendChild(taskRows);
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
dirtyEdits[epic.id] = dirty;
|
|
1489
|
-
addSubtaskInput.value = "";
|
|
1361
|
+
const addInp = document.createElement("input");
|
|
1362
|
+
addInp.className = "add-task-inp";
|
|
1363
|
+
addInp.placeholder = "+ New step… (Enter)";
|
|
1364
|
+
addInp.onkeydown = e => {
|
|
1365
|
+
if (e.key === "Enter" && addInp.value.trim()) {
|
|
1366
|
+
dirty.tasks.push({ done: false, text: addInp.value.trim(), description: "" });
|
|
1367
|
+
dirtyEdits[task.id] = dirty;
|
|
1368
|
+
addInp.value = "";
|
|
1490
1369
|
refreshRows();
|
|
1491
1370
|
}
|
|
1492
1371
|
};
|
|
1493
|
-
editTasks.appendChild(
|
|
1494
|
-
|
|
1372
|
+
editTasks.appendChild(addInp);
|
|
1373
|
+
body.appendChild(editTasks);
|
|
1374
|
+
|
|
1375
|
+
// ADR / evidence stay read-only hint
|
|
1376
|
+
if ((task.adr && task.adr.length) || (task.evidence && task.evidence.length)) {
|
|
1377
|
+
const hint = el("div", "detail-text empty");
|
|
1378
|
+
hint.textContent = "ADR and evidence are read-only in GUI (set via MCP/CLI).";
|
|
1379
|
+
body.appendChild(hint);
|
|
1380
|
+
}
|
|
1495
1381
|
|
|
1496
|
-
// Footer
|
|
1497
1382
|
const footer = el("div", "edit-footer");
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
e.stopPropagation(); delete dirtyEdits[epic.id]; editingId = null; render();
|
|
1508
|
-
};
|
|
1383
|
+
footer.appendChild(makePaperBtn("✓ Save", "btn-sm btn-confirm", async () => {
|
|
1384
|
+
await saveTaskEdit(task.id);
|
|
1385
|
+
}));
|
|
1386
|
+
footer.appendChild(makePaperBtn("Cancel", "btn-sm btn-cancel", () => {
|
|
1387
|
+
delete dirtyEdits[task.id];
|
|
1388
|
+
paperEditing = false;
|
|
1389
|
+
render();
|
|
1390
|
+
}));
|
|
1391
|
+
body.appendChild(footer);
|
|
1509
1392
|
|
|
1510
|
-
|
|
1511
|
-
card.appendChild(footer);
|
|
1393
|
+
paper.appendChild(body);
|
|
1512
1394
|
setTimeout(() => titleInput.focus(), 0);
|
|
1513
1395
|
}
|
|
1514
1396
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
body.out_of_scope = splitLines(dirty.out_of_scope);
|
|
1537
|
-
body.acceptance_criteria = splitLines(dirty.acceptance_criteria);
|
|
1538
|
-
body.test_cases = splitLines(dirty.test_cases);
|
|
1539
|
-
body.notes = dirty.notes;
|
|
1540
|
-
body.subtasks = dirty.tasks.filter(t => t.text.trim()).map((task, idx) => ({
|
|
1541
|
-
id: task.id || `st-${idx + 1}`,
|
|
1542
|
-
text: task.text.trim(),
|
|
1543
|
-
done: Boolean(task.done),
|
|
1544
|
-
description: (task.description || "").trim()
|
|
1545
|
-
}));
|
|
1546
|
-
}
|
|
1397
|
+
async function saveTaskEdit(taskId) {
|
|
1398
|
+
const dirty = dirtyEdits[taskId];
|
|
1399
|
+
if (!dirty) { paperEditing = false; render(); return; }
|
|
1400
|
+
const body = {
|
|
1401
|
+
title: dirty.title,
|
|
1402
|
+
description: dirty.description,
|
|
1403
|
+
rationale: dirty.rationale,
|
|
1404
|
+
outcome: dirty.outcome,
|
|
1405
|
+
specs: dirty.specs,
|
|
1406
|
+
in_scope: splitLines(dirty.in_scope),
|
|
1407
|
+
out_of_scope: splitLines(dirty.out_of_scope),
|
|
1408
|
+
acceptance_criteria: splitLines(dirty.acceptance_criteria),
|
|
1409
|
+
test_cases: splitLines(dirty.test_cases),
|
|
1410
|
+
notes: dirty.notes,
|
|
1411
|
+
subtasks: dirty.tasks.filter(t => t.text.trim()).map((st, idx) => ({
|
|
1412
|
+
id: st.id || `st-${idx + 1}`,
|
|
1413
|
+
text: st.text.trim(),
|
|
1414
|
+
done: Boolean(st.done),
|
|
1415
|
+
description: (st.description || "").trim()
|
|
1416
|
+
}))
|
|
1417
|
+
};
|
|
1547
1418
|
try {
|
|
1548
|
-
const res = await fetch(`/api/epics/${enc(
|
|
1419
|
+
const res = await fetch(`/api/epics/${enc(taskId)}`, {
|
|
1549
1420
|
method: "PATCH",
|
|
1550
1421
|
headers: { "Content-Type": "application/json" },
|
|
1551
|
-
body: JSON.stringify(body)
|
|
1422
|
+
body: JSON.stringify(body)
|
|
1552
1423
|
});
|
|
1553
1424
|
if (!res.ok) throw new Error();
|
|
1554
1425
|
toast("Saved ✓");
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1426
|
+
delete dirtyEdits[taskId];
|
|
1427
|
+
paperEditing = false;
|
|
1428
|
+
await load();
|
|
1429
|
+
} catch {
|
|
1430
|
+
toast("Save error!", true);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function renderEpicPaperView(paper, entity) {
|
|
1435
|
+
const p = entity.progress || {};
|
|
1436
|
+
const badges = [
|
|
1437
|
+
{ text: entity.id, cls: "" },
|
|
1438
|
+
{ text: entity.status || "empty", cls: "" }
|
|
1439
|
+
];
|
|
1440
|
+
if (entity.archived) badges.push({ text: "archived", cls: "" });
|
|
1441
|
+
|
|
1442
|
+
const actions = [
|
|
1443
|
+
makePaperBtn("Edit", "btn-sm btn-confirm", () => { paperEditing = true; render(); }),
|
|
1444
|
+
makePaperBtn(entity.archived ? "Unarchive" : "Archive", "btn-sm btn-cancel", () => {
|
|
1445
|
+
archiveEpic(entity.id, !entity.archived);
|
|
1446
|
+
}),
|
|
1447
|
+
makePaperBtn("Delete", "btn-sm btn-cancel", () => {
|
|
1448
|
+
deleteEpicEntity(entity.id, entity.title || entity.id);
|
|
1449
|
+
}),
|
|
1450
|
+
makePaperBtn("Close", "btn-sm btn-cancel", () => closePaper())
|
|
1451
|
+
];
|
|
1452
|
+
paper.appendChild(paperHead("Epic", badges, entity.title || entity.id, actions));
|
|
1453
|
+
|
|
1454
|
+
const body = el("div", "paper-body");
|
|
1455
|
+
|
|
1456
|
+
const chips = el("div", "chip-row");
|
|
1457
|
+
chips.innerHTML = `
|
|
1458
|
+
<span class="sl-chip"><strong>${p.tasks_total || 0}</strong> total</span>
|
|
1459
|
+
<span class="sl-chip active"><strong>${p.tasks_active || 0}</strong> NOW</span>
|
|
1460
|
+
<span class="sl-chip planned"><strong>${p.tasks_planned || 0}</strong> planned</span>
|
|
1461
|
+
<span class="sl-chip icebox"><strong>${p.tasks_icebox || 0}</strong> icebox</span>
|
|
1462
|
+
<span class="sl-chip done"><strong>${p.tasks_done || 0}</strong> done</span>
|
|
1463
|
+
`;
|
|
1464
|
+
body.appendChild(chips);
|
|
1465
|
+
|
|
1466
|
+
if (entity.goals) body.appendChild(renderDetailSection("Goals", entity.goals));
|
|
1467
|
+
if (entity.description) body.appendChild(renderDetailSection("What", entity.description));
|
|
1468
|
+
if (entity.rationale) body.appendChild(renderDetailSection("Rationale", entity.rationale));
|
|
1469
|
+
if (entity.outcome) body.appendChild(renderDetailSection("Outcome", entity.outcome));
|
|
1470
|
+
|
|
1471
|
+
if ((entity.in_scope && entity.in_scope.length) || (entity.out_of_scope && entity.out_of_scope.length)) {
|
|
1472
|
+
const pair = el("div", "scope-pair");
|
|
1473
|
+
pair.appendChild(renderDetailListSection("In Scope", entity.in_scope || []));
|
|
1474
|
+
pair.appendChild(renderDetailListSection("Out of Scope", entity.out_of_scope || [], "out-of-scope"));
|
|
1475
|
+
body.appendChild(pair);
|
|
1476
|
+
}
|
|
1477
|
+
if (entity.notes) body.appendChild(renderDetailSection("Notes", entity.notes));
|
|
1478
|
+
|
|
1479
|
+
// Rollup ADRs from child tasks (shapeEpic full)
|
|
1480
|
+
const adrs = Array.isArray(entity.adrs) ? entity.adrs : [];
|
|
1481
|
+
if (adrs.length > 0) {
|
|
1482
|
+
const wrap = el("div", "detail-section");
|
|
1483
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "ADR (from tasks)" }));
|
|
1484
|
+
adrs.forEach(a => {
|
|
1485
|
+
const card = el("div", "adr-card");
|
|
1486
|
+
card.innerHTML = `
|
|
1487
|
+
<div class="adr-decision">${escHtml(a.decision || "")}</div>
|
|
1488
|
+
<div class="adr-why">${escHtml(a.why || "")}</div>
|
|
1489
|
+
<div class="adr-meta">${escHtml(a.task_id || "")}${a.task_title ? " · " + escHtml(a.task_title) : ""}${a.created ? " · " + escHtml(a.created) : ""}</div>
|
|
1490
|
+
`;
|
|
1491
|
+
wrap.appendChild(card);
|
|
1492
|
+
});
|
|
1493
|
+
body.appendChild(wrap);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
paper.appendChild(body);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function renderEpicPaperEdit(paper, entity) {
|
|
1500
|
+
const dirty = dirtyEpicEdits[entity.id] || {
|
|
1501
|
+
title: entity.title || "",
|
|
1502
|
+
description: entity.description || "",
|
|
1503
|
+
rationale: entity.rationale || "",
|
|
1504
|
+
outcome: entity.outcome || "",
|
|
1505
|
+
goals: entity.goals || "",
|
|
1506
|
+
in_scope: (entity.in_scope || []).join("\n"),
|
|
1507
|
+
out_of_scope: (entity.out_of_scope || []).join("\n"),
|
|
1508
|
+
notes: entity.notes || ""
|
|
1509
|
+
};
|
|
1510
|
+
dirtyEpicEdits[entity.id] = dirty;
|
|
1511
|
+
|
|
1512
|
+
paper.appendChild(paperHead("Epic · edit", [{ text: entity.id, cls: "" }], "Editing", []));
|
|
1513
|
+
const body = el("div", "paper-body");
|
|
1514
|
+
|
|
1515
|
+
const titleWrap = el("div", "detail-section");
|
|
1516
|
+
titleWrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: "Title" }));
|
|
1517
|
+
const titleInp = document.createElement("input");
|
|
1518
|
+
titleInp.className = "edit-title-input";
|
|
1519
|
+
titleInp.value = dirty.title;
|
|
1520
|
+
titleInp.oninput = () => { dirty.title = titleInp.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1521
|
+
titleWrap.appendChild(titleInp);
|
|
1522
|
+
body.appendChild(titleWrap);
|
|
1523
|
+
|
|
1524
|
+
body.appendChild(renderEditBlock("Goals", dirty.goals, v => { dirty.goals = v; dirtyEpicEdits[entity.id] = dirty; }, "Outcome one-liner…"));
|
|
1525
|
+
body.appendChild(renderEditBlock("What", dirty.description, v => { dirty.description = v; dirtyEpicEdits[entity.id] = dirty; }, "What this initiative does…"));
|
|
1526
|
+
body.appendChild(renderEditBlock("Rationale", dirty.rationale, v => { dirty.rationale = v; dirtyEpicEdits[entity.id] = dirty; }, "Why…"));
|
|
1527
|
+
body.appendChild(renderEditBlock("Outcome", dirty.outcome, v => { dirty.outcome = v; dirtyEpicEdits[entity.id] = dirty; }, "Intended effect…"));
|
|
1528
|
+
body.appendChild(renderEditBlock("In Scope", dirty.in_scope, v => { dirty.in_scope = v; dirtyEpicEdits[entity.id] = dirty; }, "One per line"));
|
|
1529
|
+
body.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, v => { dirty.out_of_scope = v; dirtyEpicEdits[entity.id] = dirty; }, "One per line", "out-of-scope"));
|
|
1530
|
+
body.appendChild(renderEditBlock("Notes", dirty.notes, v => { dirty.notes = v; dirtyEpicEdits[entity.id] = dirty; }, "Notes"));
|
|
1531
|
+
|
|
1532
|
+
const footer = el("div", "edit-footer");
|
|
1533
|
+
footer.appendChild(makePaperBtn("✓ Save epic", "btn-sm btn-confirm", async () => {
|
|
1534
|
+
await saveEpicEdit(entity.id);
|
|
1535
|
+
}));
|
|
1536
|
+
footer.appendChild(makePaperBtn("Cancel", "btn-sm btn-cancel", () => {
|
|
1537
|
+
delete dirtyEpicEdits[entity.id];
|
|
1538
|
+
paperEditing = false;
|
|
1539
|
+
render();
|
|
1540
|
+
}));
|
|
1541
|
+
body.appendChild(footer);
|
|
1542
|
+
paper.appendChild(body);
|
|
1543
|
+
setTimeout(() => titleInp.focus(), 0);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async function saveEpicEdit(epicId) {
|
|
1547
|
+
const dirty = dirtyEpicEdits[epicId];
|
|
1548
|
+
if (!dirty) { paperEditing = false; render(); return; }
|
|
1549
|
+
const res = await fetch(`/api/epic-entities/${enc(epicId)}`, {
|
|
1550
|
+
method: "PATCH",
|
|
1551
|
+
headers: { "Content-Type": "application/json" },
|
|
1552
|
+
body: JSON.stringify({
|
|
1553
|
+
title: dirty.title.trim(),
|
|
1554
|
+
description: dirty.description,
|
|
1555
|
+
rationale: dirty.rationale,
|
|
1556
|
+
outcome: dirty.outcome,
|
|
1557
|
+
goals: dirty.goals,
|
|
1558
|
+
in_scope: splitLines(dirty.in_scope),
|
|
1559
|
+
out_of_scope: splitLines(dirty.out_of_scope),
|
|
1560
|
+
notes: dirty.notes
|
|
1561
|
+
})
|
|
1562
|
+
});
|
|
1563
|
+
if (res.ok) {
|
|
1564
|
+
delete dirtyEpicEdits[epicId];
|
|
1565
|
+
paperEditing = false;
|
|
1566
|
+
toast("Epic saved ✓");
|
|
1567
|
+
await load();
|
|
1568
|
+
} else {
|
|
1569
|
+
toast("Epic save failed", true);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ── CRUD helpers ──────────────────────────────────────────────────────────────
|
|
1574
|
+
|
|
1575
|
+
async function archiveEpic(epicId, archived) {
|
|
1576
|
+
const action = archived ? "archive" : "unarchive";
|
|
1577
|
+
await fetch(`/api/epic-entities/${enc(epicId)}/${action}`, { method: "POST" });
|
|
1558
1578
|
await load();
|
|
1559
1579
|
}
|
|
1560
1580
|
|
|
1561
|
-
function
|
|
1562
|
-
if (
|
|
1563
|
-
|
|
1564
|
-
|
|
1581
|
+
async function deleteEpicEntity(epicId, title) {
|
|
1582
|
+
if (!confirm(`Delete epic ${epicId} "${title}" and ALL its tasks? This cannot be undone.`)) return;
|
|
1583
|
+
await fetch(`/api/epic-entities/${enc(epicId)}`, { method: "DELETE" });
|
|
1584
|
+
if (paperTarget && paperTarget.kind === "epic" && paperTarget.id === epicId) {
|
|
1585
|
+
paperTarget = null;
|
|
1586
|
+
paperEditing = false;
|
|
1587
|
+
}
|
|
1588
|
+
if (filterKey === epicId) filterKey = null;
|
|
1589
|
+
await load();
|
|
1565
1590
|
}
|
|
1566
1591
|
|
|
1567
|
-
|
|
1592
|
+
async function deleteTaskCard(taskId, title) {
|
|
1593
|
+
if (!confirm(`Delete task ${taskId}: ${title}?`)) return;
|
|
1594
|
+
await fetch(`/api/tasks/${enc(taskId)}`, { method: "DELETE" });
|
|
1595
|
+
if (paperTarget && paperTarget.kind === "task" && paperTarget.id === taskId) {
|
|
1596
|
+
paperTarget = null;
|
|
1597
|
+
paperEditing = false;
|
|
1598
|
+
}
|
|
1599
|
+
await load();
|
|
1600
|
+
}
|
|
1568
1601
|
|
|
1569
|
-
function
|
|
1570
|
-
|
|
1571
|
-
|
|
1602
|
+
function toggleShowArchived() {
|
|
1603
|
+
showArchived = !showArchived;
|
|
1604
|
+
const btn = document.getElementById("btn-show-archived");
|
|
1605
|
+
if (btn) {
|
|
1606
|
+
btn.textContent = showArchived ? "Hide archived" : "Show archived";
|
|
1607
|
+
btn.classList.toggle("on", showArchived);
|
|
1608
|
+
}
|
|
1609
|
+
load();
|
|
1610
|
+
}
|
|
1572
1611
|
|
|
1573
|
-
|
|
1612
|
+
// ── Lane add ──────────────────────────────────────────────────────────────────
|
|
1574
1613
|
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1614
|
+
function showLaneAdd(container, colId, epicRef, addBtn) {
|
|
1615
|
+
const existing = container.querySelector(".lane-addinp");
|
|
1616
|
+
if (existing) { existing.remove(); return; }
|
|
1578
1617
|
|
|
1618
|
+
const wrap = el("div", "lane-addinp");
|
|
1619
|
+
wrap.appendChild(Object.assign(el("div", "lane-add-label"), { textContent: "Task title" }));
|
|
1579
1620
|
const inp = document.createElement("input");
|
|
1580
1621
|
inp.className = "lane-add-title";
|
|
1581
1622
|
inp.placeholder = "New task name…";
|
|
1582
1623
|
inp.autocomplete = "off";
|
|
1583
1624
|
|
|
1584
|
-
|
|
1585
|
-
descLabel.textContent = "Description";
|
|
1586
|
-
wrap.appendChild(descLabel);
|
|
1587
|
-
|
|
1625
|
+
wrap.appendChild(Object.assign(el("div", "lane-add-label"), { textContent: "Description" }));
|
|
1588
1626
|
const description = document.createElement("textarea");
|
|
1589
1627
|
description.className = "lane-add-textarea";
|
|
1590
|
-
description.placeholder = "
|
|
1628
|
+
description.placeholder = "Optional…";
|
|
1591
1629
|
|
|
1592
1630
|
const actions = el("div", "lane-add-actions");
|
|
1593
1631
|
const confirmBtn = document.createElement("button");
|
|
@@ -1602,17 +1640,21 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1602
1640
|
if (!title) return;
|
|
1603
1641
|
inp.disabled = true;
|
|
1604
1642
|
description.disabled = true;
|
|
1605
|
-
const
|
|
1643
|
+
const payload = {
|
|
1644
|
+
title,
|
|
1645
|
+
column: colId,
|
|
1646
|
+
description: description.value.trim()
|
|
1647
|
+
};
|
|
1648
|
+
if (epicRef) {
|
|
1649
|
+
payload.epic = epicRef;
|
|
1650
|
+
if (/^E\d+/i.test(epicRef)) payload.epic_id = epicRef;
|
|
1651
|
+
} else {
|
|
1652
|
+
payload.epic = "—";
|
|
1653
|
+
}
|
|
1606
1654
|
const res = await fetch("/api/tasks", {
|
|
1607
1655
|
method: "POST",
|
|
1608
1656
|
headers: { "Content-Type": "application/json" },
|
|
1609
|
-
body: JSON.stringify(
|
|
1610
|
-
title,
|
|
1611
|
-
column: colId,
|
|
1612
|
-
epic: epicRef,
|
|
1613
|
-
epic_id: epicRef && /^E\d+/i.test(epicRef) ? epicRef : undefined,
|
|
1614
|
-
description: description.value.trim()
|
|
1615
|
-
}),
|
|
1657
|
+
body: JSON.stringify(payload)
|
|
1616
1658
|
});
|
|
1617
1659
|
if (res.ok) {
|
|
1618
1660
|
toast("Added ✓");
|
|
@@ -1635,11 +1677,12 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1635
1677
|
actions.appendChild(confirmBtn);
|
|
1636
1678
|
actions.appendChild(cancelBtn);
|
|
1637
1679
|
wrap.appendChild(actions);
|
|
1638
|
-
|
|
1680
|
+
if (addBtn && addBtn.parentNode === container) container.insertBefore(wrap, addBtn);
|
|
1681
|
+
else container.appendChild(wrap);
|
|
1639
1682
|
inp.focus();
|
|
1640
1683
|
}
|
|
1641
1684
|
|
|
1642
|
-
// ── New task / epic forms
|
|
1685
|
+
// ── New task / epic forms ─────────────────────────────────────────────────────
|
|
1643
1686
|
|
|
1644
1687
|
function toggleNewForm() {
|
|
1645
1688
|
const epicStrip = document.getElementById("new-epic-strip");
|
|
@@ -1647,6 +1690,9 @@ function toggleNewForm() {
|
|
|
1647
1690
|
const strip = document.getElementById("new-strip");
|
|
1648
1691
|
strip.classList.toggle("open");
|
|
1649
1692
|
if (strip.classList.contains("open")) {
|
|
1693
|
+
if (filterKey && filterKey !== "—") {
|
|
1694
|
+
document.getElementById("ne-group").value = filterKey;
|
|
1695
|
+
}
|
|
1650
1696
|
document.getElementById("ne-title").focus();
|
|
1651
1697
|
}
|
|
1652
1698
|
}
|
|
@@ -1663,14 +1709,17 @@ function toggleNewEpicForm() {
|
|
|
1663
1709
|
|
|
1664
1710
|
async function submitNew() {
|
|
1665
1711
|
const title = document.getElementById("ne-title").value.trim();
|
|
1666
|
-
const col
|
|
1712
|
+
const col = document.getElementById("ne-col").value;
|
|
1667
1713
|
const group = document.getElementById("ne-group").value.trim();
|
|
1668
1714
|
if (!title) { document.getElementById("ne-title").focus(); return; }
|
|
1669
1715
|
|
|
1716
|
+
const payload = { title, column: col, epic: group || "—" };
|
|
1717
|
+
if (group && /^E\d+/i.test(group)) payload.epic_id = group;
|
|
1718
|
+
|
|
1670
1719
|
const res = await fetch("/api/tasks", {
|
|
1671
1720
|
method: "POST",
|
|
1672
1721
|
headers: { "Content-Type": "application/json" },
|
|
1673
|
-
body: JSON.stringify(
|
|
1722
|
+
body: JSON.stringify(payload)
|
|
1674
1723
|
});
|
|
1675
1724
|
if (res.ok) {
|
|
1676
1725
|
toast("Task added ✓");
|
|
@@ -1692,7 +1741,7 @@ async function submitNewEpic() {
|
|
|
1692
1741
|
const res = await fetch("/api/epics", {
|
|
1693
1742
|
method: "POST",
|
|
1694
1743
|
headers: { "Content-Type": "application/json" },
|
|
1695
|
-
body: JSON.stringify({ title, description, goals })
|
|
1744
|
+
body: JSON.stringify({ title, description, goals })
|
|
1696
1745
|
});
|
|
1697
1746
|
if (res.ok) {
|
|
1698
1747
|
toast("Epic added ✓");
|
|
@@ -1700,13 +1749,18 @@ async function submitNewEpic() {
|
|
|
1700
1749
|
document.getElementById("nx-description").value = "";
|
|
1701
1750
|
document.getElementById("nx-goals").value = "";
|
|
1702
1751
|
toggleNewEpicForm();
|
|
1752
|
+
const created = await res.json();
|
|
1753
|
+
if (created && created.id) {
|
|
1754
|
+
filterKey = created.id;
|
|
1755
|
+
openPaper("epic", created.id);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1703
1758
|
await load();
|
|
1704
1759
|
} else {
|
|
1705
1760
|
toast("Error!", true);
|
|
1706
1761
|
}
|
|
1707
1762
|
}
|
|
1708
1763
|
|
|
1709
|
-
// Key handler for new form
|
|
1710
1764
|
document.getElementById("ne-title").addEventListener("keydown", e => {
|
|
1711
1765
|
if (e.key === "Enter") submitNew();
|
|
1712
1766
|
if (e.key === "Escape") toggleNewForm();
|
|
@@ -1726,68 +1780,43 @@ function el(tag, cls) {
|
|
|
1726
1780
|
function splitLines(value) {
|
|
1727
1781
|
return String(value || "").split("\n").map(line => line.trim()).filter(Boolean);
|
|
1728
1782
|
}
|
|
1729
|
-
function appendDetailSections(card, epic) {
|
|
1730
|
-
if (epic.description) {
|
|
1731
|
-
card.appendChild(renderDetailSection("Description", epic.description));
|
|
1732
|
-
}
|
|
1733
|
-
if (epic.specs) {
|
|
1734
|
-
card.appendChild(renderDetailSection("Specs", epic.specs));
|
|
1735
|
-
}
|
|
1736
|
-
if (epic.in_scope && epic.in_scope.length > 0) {
|
|
1737
|
-
card.appendChild(renderDetailListSection("In Scope", epic.in_scope));
|
|
1738
|
-
}
|
|
1739
|
-
if (epic.out_of_scope && epic.out_of_scope.length > 0) {
|
|
1740
|
-
card.appendChild(renderDetailListSection("Out of Scope", epic.out_of_scope, "out-of-scope"));
|
|
1741
|
-
}
|
|
1742
|
-
if (epic.acceptance_criteria && epic.acceptance_criteria.length > 0) {
|
|
1743
|
-
card.appendChild(renderDetailListSection("Acceptance Criteria", epic.acceptance_criteria));
|
|
1744
|
-
}
|
|
1745
|
-
if (epic.test_cases && epic.test_cases.length > 0) {
|
|
1746
|
-
card.appendChild(renderDetailListSection("Test Cases", epic.test_cases));
|
|
1747
|
-
}
|
|
1748
|
-
if (epic.notes) {
|
|
1749
|
-
card.appendChild(renderDetailSection("Notes", epic.notes));
|
|
1750
|
-
}
|
|
1751
|
-
}
|
|
1752
1783
|
function renderDetailSection(label, text) {
|
|
1753
1784
|
const wrap = el("div", "detail-section");
|
|
1754
|
-
|
|
1755
|
-
labelEl.textContent = label;
|
|
1785
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1756
1786
|
const textEl = el("div", "detail-text");
|
|
1757
|
-
textEl
|
|
1758
|
-
wrap.appendChild(labelEl);
|
|
1787
|
+
fillRichText(textEl, text);
|
|
1759
1788
|
wrap.appendChild(textEl);
|
|
1760
1789
|
return wrap;
|
|
1761
1790
|
}
|
|
1762
1791
|
function renderDetailListSection(label, items, extraClass) {
|
|
1763
1792
|
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1764
|
-
|
|
1765
|
-
|
|
1793
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1794
|
+
if (!items || items.length === 0) {
|
|
1795
|
+
wrap.appendChild(Object.assign(el("div", "detail-text empty"), { textContent: "Nothing listed" }));
|
|
1796
|
+
return wrap;
|
|
1797
|
+
}
|
|
1766
1798
|
const list = el("ul", "detail-list");
|
|
1767
1799
|
items.forEach(item => {
|
|
1768
1800
|
const li = document.createElement("li");
|
|
1769
1801
|
li.textContent = item;
|
|
1770
1802
|
list.appendChild(li);
|
|
1771
1803
|
});
|
|
1772
|
-
wrap.appendChild(labelEl);
|
|
1773
1804
|
wrap.appendChild(list);
|
|
1774
1805
|
return wrap;
|
|
1775
1806
|
}
|
|
1776
1807
|
function renderEditBlock(label, value, onInput, placeholder, extraClass) {
|
|
1777
|
-
const wrap = el("div", extraClass ? `detail-section ${extraClass}` :
|
|
1778
|
-
|
|
1779
|
-
labelEl.textContent = label;
|
|
1808
|
+
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1809
|
+
wrap.appendChild(Object.assign(el("div", "detail-label"), { textContent: label }));
|
|
1780
1810
|
const textarea = document.createElement("textarea");
|
|
1781
1811
|
textarea.className = extraClass ? `edit-block ${extraClass}` : "edit-block";
|
|
1782
1812
|
textarea.value = value;
|
|
1783
|
-
textarea.placeholder = placeholder;
|
|
1813
|
+
textarea.placeholder = placeholder || "";
|
|
1784
1814
|
textarea.oninput = () => onInput(textarea.value);
|
|
1785
|
-
wrap.appendChild(labelEl);
|
|
1786
1815
|
wrap.appendChild(textarea);
|
|
1787
1816
|
return wrap;
|
|
1788
1817
|
}
|
|
1789
1818
|
function escHtml(s) {
|
|
1790
|
-
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
1819
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1791
1820
|
}
|
|
1792
1821
|
function enc(s) { return encodeURIComponent(s); }
|
|
1793
1822
|
|
|
@@ -1801,7 +1830,115 @@ function toast(msg, err = false) {
|
|
|
1801
1830
|
toastTimer = setTimeout(() => t.classList.remove("show"), 2500);
|
|
1802
1831
|
}
|
|
1803
1832
|
|
|
1804
|
-
// ──
|
|
1833
|
+
// ── Mermaid ───────────────────────────────────────────────────────────────────
|
|
1834
|
+
|
|
1835
|
+
let mermaidReady = false;
|
|
1836
|
+
let mermaidRunTimer = null;
|
|
1837
|
+
let mermaidSeq = 0;
|
|
1838
|
+
|
|
1839
|
+
function parseFencedSegments(text) {
|
|
1840
|
+
if (window.FencedText && typeof window.FencedText.parseFencedText === "function") {
|
|
1841
|
+
return window.FencedText.parseFencedText(text);
|
|
1842
|
+
}
|
|
1843
|
+
return text ? [{ type: "text", value: String(text) }] : [];
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function fillRichText(container, text) {
|
|
1847
|
+
container.textContent = "";
|
|
1848
|
+
const segments = parseFencedSegments(text);
|
|
1849
|
+
if (segments.length === 0) return;
|
|
1850
|
+
segments.forEach(seg => {
|
|
1851
|
+
if (seg.type === "mermaid") {
|
|
1852
|
+
const box = el("div", "md-mermaid");
|
|
1853
|
+
box.dataset.mermaidSource = seg.value;
|
|
1854
|
+
const pre = document.createElement("pre");
|
|
1855
|
+
pre.className = "mermaid";
|
|
1856
|
+
pre.textContent = seg.value;
|
|
1857
|
+
box.appendChild(pre);
|
|
1858
|
+
container.appendChild(box);
|
|
1859
|
+
} else if (seg.value) {
|
|
1860
|
+
const span = el("div", "md-text");
|
|
1861
|
+
span.textContent = seg.value;
|
|
1862
|
+
container.appendChild(span);
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function showMermaidFallback(box, source, err) {
|
|
1868
|
+
box.className = "md-mermaid-error";
|
|
1869
|
+
box.textContent = "";
|
|
1870
|
+
const label = el("div", "md-err-label");
|
|
1871
|
+
label.textContent = err && err.message
|
|
1872
|
+
? `Mermaid error: ${err.message}`
|
|
1873
|
+
: "Mermaid render failed";
|
|
1874
|
+
const pre = document.createElement("pre");
|
|
1875
|
+
pre.textContent = source || box.dataset.mermaidSource || "";
|
|
1876
|
+
box.appendChild(label);
|
|
1877
|
+
box.appendChild(pre);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function ensureMermaidInit() {
|
|
1881
|
+
if (mermaidReady) return true;
|
|
1882
|
+
if (typeof mermaid === "undefined" || !mermaid) return false;
|
|
1883
|
+
try {
|
|
1884
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "neutral" });
|
|
1885
|
+
mermaidReady = true;
|
|
1886
|
+
return true;
|
|
1887
|
+
} catch (err) {
|
|
1888
|
+
console.error("mermaid.initialize failed", err);
|
|
1889
|
+
return false;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
async function runMermaidIn(root) {
|
|
1894
|
+
const scope = root || document;
|
|
1895
|
+
const nodes = [...scope.querySelectorAll("pre.mermaid")].filter(
|
|
1896
|
+
n => !n.dataset.processed && n.textContent.trim()
|
|
1897
|
+
);
|
|
1898
|
+
if (nodes.length === 0) return;
|
|
1899
|
+
if (!ensureMermaidInit()) {
|
|
1900
|
+
nodes.forEach(pre => {
|
|
1901
|
+
const box = pre.closest(".md-mermaid") || pre.parentElement;
|
|
1902
|
+
if (box) showMermaidFallback(box, pre.textContent, new Error("mermaid library not loaded"));
|
|
1903
|
+
});
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
for (const pre of nodes) {
|
|
1907
|
+
const source = pre.textContent;
|
|
1908
|
+
const box = pre.closest(".md-mermaid") || pre.parentElement;
|
|
1909
|
+
const id = `mmd-${Date.now()}-${++mermaidSeq}`;
|
|
1910
|
+
try {
|
|
1911
|
+
const out = await mermaid.render(id, source);
|
|
1912
|
+
if (!pre.isConnected) continue;
|
|
1913
|
+
const svg = typeof out === "string" ? out : (out && out.svg);
|
|
1914
|
+
if (!svg) throw new Error("empty mermaid output");
|
|
1915
|
+
if (box && box.isConnected) {
|
|
1916
|
+
box.innerHTML = svg;
|
|
1917
|
+
box.dataset.processed = "1";
|
|
1918
|
+
} else if (pre.isConnected) {
|
|
1919
|
+
pre.outerHTML = svg;
|
|
1920
|
+
}
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
const stray = document.getElementById(id);
|
|
1923
|
+
if (stray) stray.remove();
|
|
1924
|
+
const strayD = document.getElementById("d" + id);
|
|
1925
|
+
if (strayD) strayD.remove();
|
|
1926
|
+
if (box) showMermaidFallback(box, source, err);
|
|
1927
|
+
else {
|
|
1928
|
+
pre.dataset.processed = "error";
|
|
1929
|
+
pre.title = (err && err.message) || "mermaid error";
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function queueMermaidRun(root) {
|
|
1936
|
+
clearTimeout(mermaidRunTimer);
|
|
1937
|
+
mermaidRunTimer = setTimeout(() => {
|
|
1938
|
+
runMermaidIn(root).catch(err => console.error("mermaid run failed", err));
|
|
1939
|
+
}, 0);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1805
1942
|
load();
|
|
1806
1943
|
</script>
|
|
1807
1944
|
</body>
|