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