super-backlog 1.2.0 → 1.3.1

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.
@@ -5,6 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>__PROJECT_NAME__ &middot; Project Dashboard</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='42' fill='%235cc8ff'/%3E%3C/svg%3E">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap">
8
11
  <style>
9
12
  :root {
10
13
  --bg:#0a0e16;
@@ -16,7 +19,7 @@
16
19
  --line-strong:#2c3b57;
17
20
  --text:#e8edf6;
18
21
  --muted:#8fa0ba;
19
- --dim:#5d6d88;
22
+ --dim:#72839f;
20
23
  --accent:#5cc8ff;
21
24
  --accent-dim:#234257;
22
25
  --ok:#3ecf8e;
@@ -25,27 +28,59 @@
25
28
  --warn-bg:#33260f;
26
29
  --danger:#ff7a7a;
27
30
  --danger-bg:#331718;
28
- --mono:"Cascadia Code",Consolas,"Courier New",monospace;
29
- }
30
- @font-face {
31
- font-family: 'Inter';
32
- src: local('Inter'), local('Inter-Regular');
33
- font-weight: 400; font-style: normal;
34
- }
35
- @font-face {
36
- font-family: 'Inter';
37
- src: local('Inter Medium'), local('Inter-Medium');
38
- font-weight: 500; font-style: normal;
39
- }
40
- @font-face {
41
- font-family: 'Inter';
42
- src: local('Inter SemiBold'), local('Inter-SemiBold');
43
- font-weight: 600; font-style: normal;
31
+ --sans:"Plus Jakarta Sans","Segoe UI",system-ui,sans-serif;
32
+ --mono:"JetBrains Mono",Consolas,"Courier New",monospace;
33
+ --ok-line:#2b5642;
34
+ --accent-bg:#10202e;
35
+ --accent-line:#274a63;
36
+ --warn-line:#5c4520;
37
+ --danger-line:#5c2c2c;
38
+ --backdrop:rgba(4,7,12,.65);
39
+ --tip-bg:rgba(13,19,32,.97);
40
+ --shadow-dialog:0 24px 80px rgba(0,0,0,.55);
41
+ --shadow-tip:0 10px 28px rgba(0,0,0,.5);
42
+ --glow-accent:rgba(92,200,255,.6);
43
+ --glow-warn:rgba(255,180,84,.25);
44
+ --glow-warn-soft:rgba(255,180,84,.3);
45
+ --focus-ring:rgba(92,200,255,.15);
46
+ }
47
+ :root[data-theme="light"] {
48
+ --bg:#f3f6fb;
49
+ --bg-glow-1: rgba(11,116,181,.05);
50
+ --bg-glow-2: rgba(23,122,78,.04);
51
+ --surface:#ffffff;
52
+ --surface-2:#e9eef7;
53
+ --line:#d7dfeb;
54
+ --line-strong:#b6c3d8;
55
+ --text:#17202f;
56
+ --muted:#4c5d77;
57
+ --dim:#5a6881;
58
+ --accent:#0a6ca8;
59
+ --accent-dim:#bcdcf0;
60
+ --ok:#16764b;
61
+ --ok-bg:#dff3e8;
62
+ --warn:#8a5a00;
63
+ --warn-bg:#fbecd2;
64
+ --danger:#b83a3a;
65
+ --danger-bg:#fbe3e3;
66
+ --ok-line:#9fd4b8;
67
+ --accent-bg:#e2f0f9;
68
+ --accent-line:#a8cfe6;
69
+ --warn-line:#e3c68e;
70
+ --danger-line:#eab5b5;
71
+ --backdrop:rgba(23,32,47,.45);
72
+ --tip-bg:rgba(255,255,255,.98);
73
+ --shadow-dialog:0 24px 80px rgba(23,32,47,.25);
74
+ --shadow-tip:0 10px 28px rgba(23,32,47,.18);
75
+ --glow-accent:rgba(11,116,181,.35);
76
+ --glow-warn:rgba(138,90,0,.2);
77
+ --glow-warn-soft:rgba(138,90,0,.25);
78
+ --focus-ring:rgba(11,116,181,.18);
44
79
  }
45
80
  * { box-sizing: border-box; margin: 0; padding: 0; }
46
81
  html { scroll-behavior: smooth; }
47
82
  body {
48
- font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
83
+ font-family: var(--sans);
49
84
  background:
50
85
  radial-gradient(1100px 500px at 85% -10%, var(--bg-glow-1), transparent 60%),
51
86
  radial-gradient(900px 500px at -10% 30%, var(--bg-glow-2), transparent 55%),
@@ -66,8 +101,15 @@
66
101
 
67
102
  /* ---------- Sidebar ---------- */
68
103
  .brand { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
69
- .brand-glyph { color: var(--accent); font-size: .8rem; line-height: 1; text-shadow: 0 0 12px rgba(92,200,255,.6); }
104
+ .brand-glyph { color: var(--accent); font-size: .8rem; line-height: 1; text-shadow: 0 0 12px var(--glow-accent); }
70
105
  .brand b { font-size: .98rem; letter-spacing: .4px; overflow-wrap: anywhere; }
106
+ .theme-toggle {
107
+ margin-left: auto; width: 28px; height: 28px; border-radius: 50%;
108
+ font: inherit; font-size: .9rem; line-height: 1; cursor: pointer;
109
+ color: var(--muted); background: var(--surface-2); border: 1px solid var(--line-strong);
110
+ }
111
+ .theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
112
+ .theme-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
71
113
  .kicker {
72
114
  color: var(--dim); font-size: .72rem; letter-spacing: 1.6px;
73
115
  text-transform: uppercase; margin-bottom: 22px;
@@ -88,10 +130,10 @@
88
130
  white-space: nowrap; cursor: pointer;
89
131
  }
90
132
  .pill .n { color: inherit; font-weight: 700; margin-right: 4px; }
91
- .pill[data-tone="ok"] { color: var(--ok); border-color: #2b5642; background: var(--ok-bg); }
92
- .pill[data-tone="accent"] { color: var(--accent); border-color: #274a63; background: #10202e; }
93
- .pill[data-tone="warn"] { color: var(--warn); border-color: #5c4520; background: var(--warn-bg); }
94
- .pill[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
133
+ .pill[data-tone="ok"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); }
134
+ .pill[data-tone="accent"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-bg); }
135
+ .pill[data-tone="warn"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
136
+ .pill[data-tone="danger"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
95
137
  .pill:hover { filter: brightness(1.25); }
96
138
  .pill.active { outline: 2px solid var(--accent); outline-offset: 1px; }
97
139
  .pill-empty { color: var(--dim); font-size: .78rem; }
@@ -101,20 +143,21 @@
101
143
  .term:hover { color: var(--accent); border-color: var(--accent); }
102
144
  #sbl-tip {
103
145
  position: fixed; z-index: 70; max-width: 340px; padding: 8px 12px;
104
- background: rgba(13,19,32,.97); border: 1px solid var(--line-strong); border-radius: 8px;
146
+ background: var(--tip-bg); border: 1px solid var(--line-strong); border-radius: 8px;
105
147
  font-size: .78rem; line-height: 1.45; color: var(--text);
106
- box-shadow: 0 10px 28px rgba(0,0,0,.5); pointer-events: none;
148
+ box-shadow: var(--shadow-tip); pointer-events: none;
107
149
  }
108
150
 
109
151
  /* ---------- Detail panel ---------- */
152
+ dialog { margin: auto; } /* the global reset removes the UA margin that centers dialogs */
110
153
  #task-dialog {
111
- width: min(640px, 92vw); max-height: 85vh; overflow-y: auto; padding: 0;
154
+ width: min(720px, 92vw); max-height: 85vh; overflow-y: auto; overflow-x: hidden; padding: 0;
112
155
  border: 1px solid var(--line-strong); border-radius: 14px;
113
156
  background: var(--surface); color: var(--text);
114
- box-shadow: 0 24px 80px rgba(0,0,0,.55);
157
+ box-shadow: var(--shadow-dialog);
115
158
  }
116
159
  #task-dialog::backdrop {
117
- background: rgba(4,7,12,.65);
160
+ background: var(--backdrop);
118
161
  backdrop-filter: blur(2px);
119
162
  }
120
163
  #task-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
@@ -129,10 +172,10 @@
129
172
  display: none; flex-direction: column;
130
173
  border: 1px solid var(--line-strong); border-radius: 14px;
131
174
  background: var(--surface); color: var(--text);
132
- box-shadow: 0 24px 80px rgba(0,0,0,.55);
175
+ box-shadow: var(--shadow-dialog);
133
176
  }
134
177
  #backlog-dialog[open] { display: flex; animation: sbl-dialog-in .18s ease-out; }
135
- #backlog-dialog::backdrop { background: rgba(4,7,12,.65); backdrop-filter: blur(2px); }
178
+ #backlog-dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
136
179
  @media (prefers-reduced-motion: reduce) {
137
180
  #backlog-dialog[open] { animation: none; }
138
181
  }
@@ -154,26 +197,49 @@
154
197
  #backlog-close:hover { color: var(--text); border-color: var(--line); }
155
198
  #backlog-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
156
199
  #backlog-frame { flex: 1; width: 100%; border: 0; background: var(--bg); }
157
- .dialog-content { padding: 22px 24px 28px; }
158
- .detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
200
+ .dialog-content { padding: 0 24px 26px; }
201
+ .detail-head {
202
+ position: sticky; top: 0; z-index: 1;
203
+ display: flex; align-items: center; gap: 10px;
204
+ margin: 0 -24px 12px; padding: 16px 24px 12px;
205
+ background: var(--surface); border-bottom: 1px solid var(--line);
206
+ }
159
207
  .detail-id { font-family: var(--mono); color: var(--accent); font-weight: 700; }
160
208
  .status-chip { font-family: var(--mono); font-size: .68rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line-strong); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
161
- .status-chip[data-tone="ok"] { color: var(--ok); border-color: #2b5642; background: var(--ok-bg); }
162
- .status-chip[data-tone="accent"] { color: var(--accent); border-color: #274a63; background: #10202e; }
163
- .status-chip[data-tone="warn"] { color: var(--warn); border-color: #5c4520; background: var(--warn-bg); }
164
- .status-chip[data-tone="danger"] { color: var(--danger); border-color: #5c2c2c; background: var(--danger-bg); }
209
+ .status-chip[data-tone="ok"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-bg); }
210
+ .status-chip[data-tone="accent"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-bg); }
211
+ .status-chip[data-tone="warn"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-bg); }
212
+ .status-chip[data-tone="danger"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-bg); }
165
213
  .detail-close { margin-left: auto; background: none; border: none; color: var(--dim); font-size: 1.3rem; cursor: pointer; line-height: 1; }
166
214
  .detail-close:hover { color: var(--danger); }
167
- .detail-title { font-size: 1.18rem; font-weight: 600; margin-bottom: 8px; overflow-wrap: anywhere; }
168
- #task-dialog .detail-desc { color: var(--muted); margin-bottom: 14px; white-space: normal; }
169
- #task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 16px 0 6px; }
170
- .meta-row { display: flex; flex-wrap: wrap; gap: 14px; font-size: .85rem; color: var(--muted); }
171
- .meta-row span { display: flex; align-items: center; gap: 4px; }
172
- .meta-label { color: var(--dim); }
215
+ .detail-title { font-size: 1.3rem; font-weight: 700; line-height: 1.3; margin-bottom: 10px; overflow-wrap: anywhere; }
216
+ #task-dialog .detail-desc { color: var(--muted); line-height: 1.6; margin-bottom: 10px; white-space: normal; }
217
+ #task-dialog h4 { color: var(--dim); font-size: .7rem; letter-spacing: 1.3px; text-transform: uppercase; margin: 18px 0 8px; }
218
+ .detail-meta {
219
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
220
+ gap: 12px 18px; margin: 16px 0 4px; padding: 14px 16px;
221
+ border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2);
222
+ }
223
+ .meta-cell .meta-label {
224
+ display: block; color: var(--dim); font-size: .66rem;
225
+ letter-spacing: 1.2px; text-transform: uppercase; margin-bottom: 4px;
226
+ }
227
+ .meta-value { font-size: .88rem; color: var(--text); overflow-wrap: anywhere; }
228
+ .meta-value.empty { color: var(--dim); }
229
+ .meta-value[data-tone="danger"] { color: var(--danger); }
230
+ .meta-value[data-tone="warn"] { color: var(--warn); }
231
+ .meta-value[data-tone="dim"] { color: var(--muted); }
232
+ .ac-progress { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }
233
+ .ac-count { font-family: var(--mono); font-size: .78rem; color: var(--muted); white-space: nowrap; }
234
+ .ac-bar { flex: 1; height: 4px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
235
+ .ac-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); }
173
236
  .dep-row { display: flex; flex-wrap: wrap; gap: 6px; }
237
+ .dep-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 7px; background: var(--dim); }
238
+ .dep-dot.done { background: var(--ok); }
239
+ .detail-cmd { margin-top: 18px; }
174
240
  .dep-link {
175
241
  font-family: var(--mono); font-size: .74rem; padding: 2px 10px; border-radius: 999px;
176
- border: 1px solid #274a63; background: #10202e; color: var(--accent); cursor: pointer;
242
+ border: 1px solid var(--accent-line); background: var(--accent-bg); color: var(--accent); cursor: pointer;
177
243
  }
178
244
  .dep-link:hover { filter: brightness(1.25); }
179
245
 
@@ -189,7 +255,7 @@
189
255
  background: linear-gradient(180deg, var(--accent), transparent 130%);
190
256
  -webkit-background-clip: text; background-clip: text; color: transparent;
191
257
  }
192
- .sec-head h2 { font-size: 1.15rem; letter-spacing: .5px; }
258
+ .sec-head h2 { font-size: 1.15rem; letter-spacing: .5px; font-weight: 700; }
193
259
  .sec-head .tagline { color: var(--dim); font-size: .82rem; margin-left: auto; text-align: right; }
194
260
  .mount { min-height: 24px; }
195
261
 
@@ -204,12 +270,11 @@
204
270
  }
205
271
  .cmd-btn:hover { background: var(--surface-2); border-color: var(--accent); }
206
272
  .cmd-btn:active { transform: translateY(1px); }
207
- .cmd-title { font-size: .95rem; font-weight: 600; color: var(--text); }
273
+ .cmd-title { font-size: .95rem; font-weight: 700; color: var(--text); }
208
274
  .cmd-btn:hover .cmd-title { color: var(--accent); }
209
275
  .cmd-line { font-family: var(--mono); font-size: .72rem; color: var(--dim); }
210
276
  .hint { color: var(--dim); font-size: .8rem; margin-top: 12px; }
211
277
  .drafts-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
212
- .drafts-list li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; font-size: .88rem; }
213
278
  code.inline {
214
279
  font-family: var(--mono); font-size: .85em;
215
280
  background: var(--surface-2); border: 1px solid var(--line);
@@ -222,7 +287,7 @@
222
287
  background: var(--surface-2); color: var(--text); border: 1px solid var(--line-strong);
223
288
  border-radius: 999px; padding: 7px 16px; min-width: 260px; font: inherit;
224
289
  }
225
- input[type="search"]:focus { outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px rgba(92,200,255,.15); }
290
+ input[type="search"]:focus { outline: none; border-color: var(--accent-dim); box-shadow: 0 0 0 2px var(--focus-ring); }
226
291
  .table-wrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; }
227
292
  table { width: 100%; border-collapse: collapse; font-size: .89rem; }
228
293
  th, td { text-align: left; padding: 8px 14px; border-bottom: 1px solid var(--line); vertical-align: top; white-space: nowrap; }
@@ -238,7 +303,8 @@
238
303
  .task-row:hover td { background: var(--surface-2); }
239
304
  .cell-id { color: var(--accent); font-family: var(--mono); font-variant-numeric: tabular-nums; }
240
305
  .cell-title { font-weight: 400; white-space: normal; min-width: 220px; }
241
- .cell-updated { font-family: var(--mono); color: var(--muted); }
306
+ .cell-updated { font-family: var(--mono); color: var(--muted); text-align: center; }
307
+ th[data-key="updated"] { text-align: center; }
242
308
 
243
309
  /* ---------- Footer ---------- */
244
310
  .footer {
@@ -257,6 +323,30 @@
257
323
  .legend-list li:hover { color: var(--text); }
258
324
  .legend-list .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
259
325
 
326
+ /* ---------- KPI tiles ---------- */
327
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 12px; margin-bottom: 22px; }
328
+ .kpi {
329
+ display: block; text-align: left; font: inherit; color: inherit; cursor: default;
330
+ background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
331
+ }
332
+ .kpi-big { font-family: var(--mono); font-size: 1.7rem; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
333
+ .kpi-cap { display: block; color: var(--dim); font-size: .66rem; letter-spacing: 1.4px; text-transform: uppercase; margin-top: 4px; }
334
+ .kpi-sub { display: block; font-size: .78rem; color: var(--muted); margin-top: 8px; }
335
+ .kpi-sub[data-tone="ok"] { color: var(--ok); }
336
+ .kpi-sub[data-tone="danger"] { color: var(--danger); }
337
+ .kpi-link { font: inherit; background: none; border: none; padding: 0; color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: .78rem; }
338
+ .kpi-link:hover { filter: brightness(1.25); }
339
+ .kpi-special-btn { display: block; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: none; border-radius: 8px; padding: 2px 4px; margin: -2px -4px; cursor: pointer; }
340
+ .kpi-special-btn:hover { background: var(--surface-2); }
341
+ .kpi-special-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
342
+ .kpi-special-btn[aria-pressed="true"] { background: var(--accent-bg); }
343
+ /* ---------- Aging strip ---------- */
344
+ .aging-lane-label { fill: var(--text); font-size: 12px; font-family: var(--mono); }
345
+ .aging-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
346
+ .aging-track { stroke: var(--line); }
347
+ .age-dot { cursor: pointer; }
348
+ .age-dot:hover { opacity: .8; }
349
+
260
350
  /* ---------- Milestone bars ---------- */
261
351
  .bars text { font-family: var(--mono); }
262
352
  .bar-label { fill: var(--text); font-size: 12.5px; }
@@ -265,16 +355,17 @@
265
355
  .bar-fill { fill: var(--accent); }
266
356
  .bar-fill.complete { fill: var(--ok); }
267
357
 
268
- /* ---------- Sparkline ---------- */
269
- .spark-line {
270
- fill: none; stroke: var(--accent); stroke-width: 2;
271
- stroke-linejoin: round; stroke-linecap: round;
272
- filter: drop-shadow(0 0 6px rgba(92,200,255,.4));
273
- }
274
- .spark-area { fill: rgba(92,200,255,.09); stroke: none; }
275
- .spark-dot { fill: var(--bg); stroke: var(--dim); stroke-width: 1; }
276
- .spark-dot.nonzero { fill: var(--accent); stroke: var(--accent); }
277
- .spark-axis { fill: var(--dim); font-size: 10px; font-family: var(--mono); }
358
+ /* ---------- Activity heatmap ---------- */
359
+ .hm-cell { cursor: pointer; }
360
+ .hm-cell:hover { stroke: var(--accent); stroke-width: 1; }
361
+ .hm-0 { fill: var(--surface-2); }
362
+ .hm-1 { fill: var(--accent); opacity: .3; }
363
+ .hm-2 { fill: var(--accent); opacity: .55; }
364
+ .hm-3 { fill: var(--accent); opacity: .8; }
365
+ .hm-4 { fill: var(--accent); }
366
+ .hm-axis { fill: var(--dim); font-size: 9px; font-family: var(--mono); }
367
+ #day-panel { margin-top: 16px; }
368
+ #day-panel h4 { font-family: var(--mono); }
278
369
 
279
370
  /* ---------- Pipeline stepper ---------- */
280
371
  .stepper { display: grid; grid-template-columns: repeat(9, minmax(0, 1fr)); gap: 2px; margin: 4px 0 6px; }
@@ -298,7 +389,7 @@
298
389
  border-radius: 50%; font-family: var(--mono); font-size: .9rem; font-weight: 700;
299
390
  background: var(--surface-2); border: 2px solid var(--line-strong); color: var(--muted);
300
391
  }
301
- .step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px rgba(255,180,84,.25); }
392
+ .step.gate .step-num { border-color: var(--warn); color: var(--warn); background: var(--warn-bg); box-shadow: 0 0 14px var(--glow-warn); }
302
393
  .step-label {
303
394
  display: block; font-size: .8rem; font-weight: 600; color: var(--text); line-height: 1.3;
304
395
  overflow-wrap: break-word;
@@ -343,10 +434,26 @@
343
434
  color: var(--warn); background: var(--warn-bg); border: 1px solid var(--warn);
344
435
  border-radius: 999px; padding: 2px 9px; transition: box-shadow .15s ease;
345
436
  }
346
- .update-badge:hover { box-shadow: 0 0 10px rgba(255,180,84,.3); }
437
+ .update-badge:hover { box-shadow: 0 0 10px var(--glow-warn-soft); }
347
438
  .update-badge:focus-visible { outline: 2px solid var(--warn); outline-offset: 2px; }
348
439
  .update-badge .cmd-title { font-size: inherit; font-weight: 600; color: inherit; }
349
440
 
441
+ /* ---------- Models modal ---------- */
442
+ #models-dialog {
443
+ width: min(560px, 92vw); max-height: 80vh; overflow-y: auto; overflow-x: hidden; padding: 0;
444
+ border: 1px solid var(--line-strong); border-radius: 14px;
445
+ background: var(--surface); color: var(--text);
446
+ box-shadow: var(--shadow-dialog);
447
+ }
448
+ #models-dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
449
+ #models-dialog[open] { animation: sbl-dialog-in .18s ease-out; }
450
+ @media (prefers-reduced-motion: reduce) {
451
+ #models-dialog[open] { animation: none; }
452
+ }
453
+ .phase-cmd:disabled { opacity: .6; cursor: default; }
454
+ .models-head-title { font-weight: 700; }
455
+ .models-row { display: flex; align-items: center; gap: 10px; margin: 14px 0; flex-wrap: wrap; }
456
+
350
457
  /* ---------- Dependency flow ---------- */
351
458
  .sub-head {
352
459
  font-size: .78rem; letter-spacing: 1.4px; text-transform: uppercase;
@@ -379,22 +486,35 @@
379
486
  main { padding: 24px 20px 40px; }
380
487
  }
381
488
  </style>
489
+ <script>
490
+ (function () {
491
+ var theme = null;
492
+ try { theme = localStorage.getItem('sbl-theme'); } catch (e) { theme = null; }
493
+ if (theme !== 'light' && theme !== 'dark') {
494
+ theme = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
495
+ }
496
+ document.documentElement.setAttribute('data-theme', theme);
497
+ })();
498
+ </script>
382
499
  </head>
383
500
  <body>
384
501
  <div class="layout">
385
502
 
386
503
  <aside class="sbl-side">
387
- <div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b></div>
504
+ <div class="brand"><span class="brand-glyph">●</span><b>__PROJECT_NAME__</b>
505
+ <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Switch color theme">◐</button>
506
+ </div>
388
507
  <div class="kicker">SUPERPOWERS × BACKLOG.MD</div>
389
508
  <div class="side-version" id="side-version"><span>v__KIT_VERSION__</span></div>
390
509
  <nav aria-label="Dashboard sections">
391
510
  <a href="#sec-01"><span class="n">01</span>Board &amp; Quick Actions</a>
392
511
  <a href="#sec-02"><span class="n">02</span>Status</a>
393
- <a href="#sec-03"><span class="n">03</span>Milestones</a>
394
- <a href="#sec-04"><span class="n">04</span>Tasks</a>
395
- <a href="#sec-05"><span class="n">05</span>Feature Cycle</a>
396
- <a href="#sec-06"><span class="n">06</span>Activity</a>
397
- <a href="#sec-07"><span class="n">07</span>Decisions &amp; Docs</a>
512
+ <a href="#sec-03"><span class="n">03</span>Feature Cycle</a>
513
+ <a href="#sec-04"><span class="n">04</span>Milestones</a>
514
+ <a href="#sec-05"><span class="n">05</span>Drafts</a>
515
+ <a href="#sec-06"><span class="n">06</span>Tasks</a>
516
+ <a href="#sec-07"><span class="n">07</span>Activity</a>
517
+ <a href="#sec-08"><span class="n">08</span>Decisions &amp; Docs</a>
398
518
  </nav>
399
519
  <div class="legend">
400
520
  <h4>Status</h4>
@@ -411,27 +531,42 @@
411
531
  <div id="quickactions" class="mount">
412
532
  <div class="cmd-row" id="cmd-buttons">
413
533
  <button type="button" class="cmd-btn" id="backlog-btn"><span class="cmd-title">Backlog</span><span class="cmd-line">board &middot; tasks &middot; docs &middot; decisions</span></button>
534
+ <button type="button" class="cmd-btn" id="models-btn"><span class="cmd-title">Model Router</span><span class="cmd-line">workhorse &middot; budget &middot; tiers</span></button>
414
535
  </div>
415
536
  <p class="hint">Opens the Backlog.md browser in an overlay &mdash; served locally per project, started on demand.</p>
416
537
  </div>
417
- <div id="drafts" class="mount">
418
- <h3 class="sub-head">Drafts</h3>
419
- <ul id="drafts-list" class="drafts-list"></ul>
420
- </div>
421
538
  </section>
422
539
 
423
540
  <section id="sec-02">
424
- <div class="sec-head"><span class="sec-num">02</span><h2>Status</h2><span class="tagline">tasks per status</span></div>
541
+ <div class="sec-head"><span class="sec-num">02</span><h2>Status</h2><span class="tagline">progress &middot; velocity &middot; wip &middot; age</span></div>
542
+ <div id="kpis" class="mount"></div>
425
543
  <div id="donut" class="mount"></div>
544
+ <h3 class="sub-head">Aging &mdash; open tasks by days in the backlog</h3>
545
+ <div id="aging" class="mount"></div>
426
546
  </section>
427
547
 
428
548
  <section id="sec-03">
429
- <div class="sec-head"><span class="sec-num">03</span><h2>Milestones</h2><span class="tagline">done / total per <span class="term" data-term="Milestone">Milestone</span></span></div>
430
- <div id="bars" class="mount"></div>
549
+ <div class="sec-head"><span class="sec-num">03</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included &middot; click a step for details</span></div>
550
+ <div id="stepper" class="mount"></div>
551
+ <div id="phase-detail" hidden></div>
552
+ <h3 class="sub-head">Flow</h3>
553
+ <div id="depgraph" class="mount"></div>
431
554
  </section>
432
555
 
433
556
  <section id="sec-04">
434
- <div class="sec-head"><span class="sec-num">04</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; click row for details</span></div>
557
+ <div class="sec-head"><span class="sec-num">04</span><h2>Milestones</h2><span class="tagline">done / total per <span class="term" data-term="Milestone">Milestone</span></span></div>
558
+ <div id="bars" class="mount"></div>
559
+ </section>
560
+
561
+ <section id="sec-05">
562
+ <div class="sec-head"><span class="sec-num">05</span><h2>Drafts</h2><span class="tagline">ideas before they enter the backlog &middot; click a card for details</span></div>
563
+ <div id="drafts" class="mount">
564
+ <ul id="drafts-list" class="drafts-list"></ul>
565
+ </div>
566
+ </section>
567
+
568
+ <section id="sec-06">
569
+ <div class="sec-head"><span class="sec-num">06</span><h2>Tasks</h2><span class="tagline">sort &middot; filter &middot; click row for details</span></div>
435
570
  <div id="tasks" class="mount">
436
571
  <div class="toolbar">
437
572
  <input id="taskfilter" type="search" placeholder="Filter tasks&hellip;" aria-label="Filter tasks">
@@ -455,21 +590,15 @@
455
590
  </div>
456
591
  </section>
457
592
 
458
- <section id="sec-05">
459
- <div class="sec-head"><span class="sec-num">05</span><h2>Feature Cycle</h2><span class="tagline">idea &rarr; merge &middot; every <span class="term" data-term="Review Gate">Review Gate</span> included &middot; click a step for details</span></div>
460
- <div id="stepper" class="mount"></div>
461
- <div id="phase-detail" hidden></div>
462
- <h3 class="sub-head">Flow</h3>
463
- <div id="depgraph" class="mount"></div>
464
- </section>
465
-
466
- <section id="sec-06">
467
- <div class="sec-head"><span class="sec-num">06</span><h2>Activity</h2><span class="tagline">last 30 days</span></div>
468
- <div id="spark" class="mount"></div>
593
+ <section id="sec-07">
594
+ <div class="sec-head"><span class="sec-num">07</span><h2>Activity</h2><span class="tagline">last 26 weeks &middot; click a day for its tasks</span></div>
595
+ <div id="activity-kpis" class="mount"></div>
596
+ <div id="heatmap" class="mount"></div>
597
+ <div id="day-panel" class="flow-block" hidden></div>
469
598
  </section>
470
599
 
471
- <section id="sec-07">
472
- <div class="sec-head"><span class="sec-num">07</span><h2>Decisions &amp; Docs</h2><span class="tagline">decisions &middot; docs &middot; glossary</span></div>
600
+ <section id="sec-08">
601
+ <div class="sec-head"><span class="sec-num">08</span><h2>Decisions &amp; Docs</h2><span class="tagline">decisions &middot; docs &middot; glossary</span></div>
473
602
  <div id="docs" class="mount"></div>
474
603
  <p class="hint">The <span class="term" data-term="TDD">TDD</span> loop and the <span class="term" data-term="DoD">DoD</span> ship as built-in glossary terms &mdash; extend them via <code class="inline">backlog/docs/glossary.md</code>.</p>
475
604
  </section>
@@ -482,7 +611,7 @@
482
611
  </main>
483
612
  </div>
484
613
 
485
- <dialog id="task-dialog" aria-label="Task details"></dialog>
614
+ <dialog id="task-dialog"></dialog>
486
615
 
487
616
  <dialog id="backlog-dialog" aria-label="Backlog browser">
488
617
  <div class="backlog-dialog-bar">
@@ -492,6 +621,17 @@
492
621
  </div>
493
622
  <iframe id="backlog-frame" title="Backlog.md browser"></iframe>
494
623
  </dialog>
624
+
625
+ <dialog id="models-dialog" aria-label="Model router">
626
+ <div class="dialog-content">
627
+ <div class="detail-head">
628
+ <span class="detail-id">MODELS</span>
629
+ <span class="models-head-title">Model router</span>
630
+ <button type="button" class="detail-close" id="models-close" aria-label="Close">&#215;</button>
631
+ </div>
632
+ <div id="models-body"></div>
633
+ </div>
634
+ </dialog>
495
635
  <div id="sbl-tip" role="tooltip" hidden></div>
496
636
 
497
637
  <script type="application/json" id="sbl-data">__SBL_DATA_JSON__</script>
@@ -525,7 +665,7 @@
525
665
  }
526
666
 
527
667
  var KEYS = ['id', 'title', 'status', 'milestone', 'priority', 'assignee', 'updated'];
528
- var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null };
668
+ var state = { key: 'id', dir: 1, query: '', status: null, hoverStatus: null, special: null };
529
669
  function field(task, key) {
530
670
  var v = task[key];
531
671
  return v === undefined || v === null ? '' : String(v);
@@ -536,6 +676,8 @@
536
676
  function matches(task) {
537
677
  var eff = activeStatus();
538
678
  if (eff && field(task, 'status').toLowerCase() !== eff.toLowerCase()) return false;
679
+ if (state.special === 'wip' && !isWipStatus(task.status)) return false;
680
+ if (state.special === 'blocked' && !isBlockedTask(task)) return false;
539
681
  if (!state.query) return true;
540
682
  var hit = KEYS.some(function (k) {
541
683
  return field(task, k).toLowerCase().indexOf(state.query) !== -1;
@@ -544,13 +686,68 @@
544
686
  return ac.text.toLowerCase().indexOf(state.query) !== -1;
545
687
  });
546
688
  }
689
+ function isWipStatus(status) {
690
+ var s = String(status || '').toLowerCase();
691
+ return s.indexOf('progress') !== -1 || s.indexOf('review') !== -1;
692
+ }
693
+ function isBlockedTask(task) {
694
+ var s = String(task.status || '').toLowerCase();
695
+ if (isDoneStatus(task.status)) return false;
696
+ if (s.indexOf('block') !== -1) return true;
697
+ if (!depsOut) return false;
698
+ var blockers = depsOut[task.id] || [];
699
+ for (var i = 0; i < blockers.length; i++) {
700
+ var dep = findTask(blockers[i]);
701
+ if (dep && !isDoneStatus(dep.status)) return true;
702
+ }
703
+ return false;
704
+ }
705
+ function setSpecialFilter(kind) {
706
+ state.special = state.special === kind ? null : kind;
707
+ state.status = null;
708
+ document.querySelectorAll('#pills .pill').forEach(function (pill) { pill.classList.remove('active'); });
709
+ document.querySelectorAll('[data-special]').forEach(function (tile) {
710
+ tile.setAttribute('aria-pressed', String(state.special === tile.getAttribute('data-special')));
711
+ });
712
+ renderTasks();
713
+ }
547
714
  var tbody = $('#task-rows');
715
+ function compareTasks(a, b, key) {
716
+ if (key === 'updated') {
717
+ var ta = Date.parse(field(a, key)) || 0;
718
+ var tb = Date.parse(field(b, key)) || 0;
719
+ if (ta !== tb) return ta - tb;
720
+ }
721
+ return field(a, key).localeCompare(field(b, key), undefined, { numeric: true, sensitivity: 'base' });
722
+ }
723
+ var relFmt = window.Intl && Intl.RelativeTimeFormat ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) : null;
724
+ var exactFmt = window.Intl && Intl.DateTimeFormat ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : null;
725
+ var REL_UNITS = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
726
+ function parseWhen(value) {
727
+ if (!value) return null;
728
+ /* date-only strings get a local noon so the day never shifts across timezones */
729
+ var t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(value) ? value + 'T12:00:00' : value);
730
+ return isNaN(t) ? null : t;
731
+ }
732
+ function formatRelative(value) {
733
+ var t = parseWhen(value);
734
+ if (t === null || !relFmt) return value;
735
+ var diff = (t - Date.now()) / 1000;
736
+ for (var i = 0; i < REL_UNITS.length; i++) {
737
+ if (Math.abs(diff) >= REL_UNITS[i][1] || i === REL_UNITS.length - 1) {
738
+ return relFmt.format(Math.round(diff / REL_UNITS[i][1]), REL_UNITS[i][0]);
739
+ }
740
+ }
741
+ return value;
742
+ }
743
+ function formatExact(value) {
744
+ var t = parseWhen(value);
745
+ return t === null || !exactFmt ? '' : exactFmt.format(new Date(t));
746
+ }
548
747
  function renderTasks() {
549
748
  var rows = data.tasks
550
749
  .filter(matches)
551
- .sort(function (a, b) {
552
- return field(a, state.key).localeCompare(field(b, state.key)) * state.dir;
553
- });
750
+ .sort(function (a, b) { return compareTasks(a, b, state.key) * state.dir; });
554
751
  tbody.textContent = '';
555
752
  if (rows.length === 0) {
556
753
  var emptyTr = el('tr');
@@ -564,7 +761,19 @@
564
761
  var tr = el('tr', 'task-row');
565
762
  tr.setAttribute('data-task', task.id);
566
763
  KEYS.forEach(function (k) {
567
- tr.appendChild(el('td', 'cell-' + k, field(task, k)));
764
+ var td = el('td', 'cell-' + k);
765
+ if (k === 'status') {
766
+ var chip = el('span', 'status-chip', field(task, k));
767
+ chip.setAttribute('data-tone', toneOf(task.status));
768
+ td.appendChild(chip);
769
+ } else if (k === 'updated') {
770
+ td.textContent = formatRelative(field(task, k));
771
+ var exact = formatExact(field(task, k));
772
+ if (exact) td.setAttribute('data-tip', exact);
773
+ } else {
774
+ td.textContent = field(task, k);
775
+ }
776
+ tr.appendChild(td);
568
777
  });
569
778
  tr.addEventListener('click', function () { openDetail(task.id); });
570
779
  tbody.appendChild(tr);
@@ -598,9 +807,13 @@
598
807
  }
599
808
  function setStatusFilter(status) {
600
809
  state.status = status || null;
810
+ state.special = null;
601
811
  document.querySelectorAll('#pills .pill').forEach(function (pill) {
602
812
  pill.classList.toggle('active', !!state.status && pill.getAttribute('data-status') === state.status);
603
813
  });
814
+ document.querySelectorAll('[data-special]').forEach(function (tile) {
815
+ tile.setAttribute('aria-pressed', 'false');
816
+ });
604
817
  renderTasks();
605
818
  }
606
819
  document.querySelectorAll('#pills .pill').forEach(function (pill) {
@@ -660,15 +873,106 @@
660
873
  });
661
874
  }
662
875
 
663
- /* ---------- Drafts ---------- */
664
- function renderDrafts(drafts) {
665
- var list = document.getElementById('drafts-list');
666
- if (!list) return;
667
- list.textContent = '';
668
- if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
669
- drafts.forEach(function (d) { list.appendChild(el('li', '', d.id + ' \u2014 ' + d.title)); });
876
+ /* ---------- Theme toggle ---------- */
877
+ var themeToggle = document.getElementById('theme-toggle');
878
+ function syncThemeToggle() {
879
+ if (!themeToggle) return;
880
+ var mode = document.documentElement.getAttribute('data-theme');
881
+ themeToggle.textContent = mode === 'light' ? '' : '☀';
882
+ themeToggle.setAttribute('aria-label', mode === 'light' ? 'Switch to dark theme' : 'Switch to light theme');
883
+ }
884
+ if (themeToggle) {
885
+ themeToggle.addEventListener('click', function () {
886
+ var next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
887
+ document.documentElement.setAttribute('data-theme', next);
888
+ try { localStorage.setItem('sbl-theme', next); } catch (e) { /* private mode */ }
889
+ syncThemeToggle();
890
+ });
891
+ syncThemeToggle();
892
+ }
893
+
894
+ /* ---------- Models modal ---------- */
895
+ var modelsBtn = document.getElementById('models-btn');
896
+ var modelsDialog = document.getElementById('models-dialog');
897
+ var modelsBody = document.getElementById('models-body');
898
+ function tierCell(label, value) {
899
+ var c = el('div', 'meta-cell');
900
+ c.appendChild(el('span', 'meta-label', label));
901
+ c.appendChild(el('span', 'meta-value' + (value ? '' : ' empty'), value || '—'));
902
+ return c;
903
+ }
904
+ function actionButton(cmdLine, label, onClick) {
905
+ var b = el('button', 'phase-cmd');
906
+ b.type = 'button';
907
+ b.appendChild(el('span', 'cmd-line', cmdLine));
908
+ b.appendChild(el('span', 'cmd-title', label));
909
+ b.addEventListener('click', onClick);
910
+ return b;
911
+ }
912
+ function renderTiers(mount, tiers) {
913
+ mount.textContent = '';
914
+ if (!tiers || tiers.error || (!tiers.workhorse && !tiers.budget)) {
915
+ mount.appendChild(el('p', 'hint', 'Discovery failed — is the OpenCode CLI available?'));
916
+ return;
917
+ }
918
+ mount.appendChild(tierCell('Workhorse', tiers.workhorse));
919
+ mount.appendChild(tierCell('Budget', tiers.budget));
920
+ }
921
+ function renderModelsDialog(info) {
922
+ if (!modelsBody) return;
923
+ modelsBody.textContent = '';
924
+ var enabled = !!(info.config && info.config.enabled);
925
+ var row = el('div', 'models-row');
926
+ var chip = el('span', 'status-chip', info.installed ? (enabled ? 'enabled' : 'disabled') : 'not installed');
927
+ if (info.installed) chip.setAttribute('data-tone', enabled ? 'ok' : 'warn');
928
+ row.appendChild(chip);
929
+ var toggle = actionButton(
930
+ enabled ? 'sbl models disable' : 'sbl models enable',
931
+ enabled ? 'disable' : 'enable',
932
+ function () {
933
+ fetch('api/models/' + (enabled ? 'disable' : 'enable'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
934
+ .then(function (res) { if (!res.ok) throw new Error('toggle failed'); return res.json(); })
935
+ .then(function (next) { renderModelsDialog(next); })
936
+ .catch(function () { cmdFeedback(toggle, 'failed ✗'); });
937
+ },
938
+ );
939
+ row.appendChild(toggle);
940
+ var discover = actionButton('sbl models discover', 'run', function () {
941
+ discover.disabled = true;
942
+ cmdFeedback(discover, 'running…');
943
+ fetch('api/models/discover', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
944
+ .then(function (res) { return res.json(); })
945
+ .then(function (r) { renderTiers(document.getElementById('models-tiers'), r); })
946
+ .catch(function () { cmdFeedback(discover, 'failed ✗'); })
947
+ .then(function () { discover.disabled = false; });
948
+ });
949
+ row.appendChild(discover);
950
+ modelsBody.appendChild(row);
951
+ var tiers = el('div', 'detail-meta');
952
+ tiers.id = 'models-tiers';
953
+ var resolved = (info.config && info.config.resolved) || {};
954
+ tiers.appendChild(tierCell('Workhorse', resolved.workhorse));
955
+ tiers.appendChild(tierCell('Budget', resolved.budget));
956
+ modelsBody.appendChild(tiers);
957
+ if (!info.installed) {
958
+ modelsBody.appendChild(el('p', 'hint', 'Install the router first, then enable it:'));
959
+ var hint = actionButton('sbl init --models', 'copy', function () { copyCommand(hint, 'sbl init --models'); });
960
+ modelsBody.appendChild(hint);
961
+ }
962
+ }
963
+ if (modelsBtn && modelsDialog) {
964
+ modelsBtn.addEventListener('click', function () {
965
+ fetch('api/models')
966
+ .then(function (res) { if (!res.ok) throw new Error('load failed'); return res.json(); })
967
+ .then(function (info) { renderModelsDialog(info); modelsDialog.showModal(); })
968
+ .catch(function () {});
969
+ });
970
+ var modelsClose = document.getElementById('models-close');
971
+ if (modelsClose) modelsClose.addEventListener('click', function () { modelsDialog.close(); });
972
+ modelsDialog.addEventListener('click', function (e) {
973
+ if (e.target === modelsDialog) modelsDialog.close();
974
+ });
670
975
  }
671
- renderDrafts(data.drafts);
672
976
 
673
977
  /* ---------- Shared diagram helpers ---------- */
674
978
  var SVGNS = 'http://www.w3.org/2000/svg';
@@ -754,6 +1058,123 @@
754
1058
  mount.appendChild(wrap);
755
1059
  }
756
1060
 
1061
+ var APPROX_TIP = 'Approximation from created/updated timestamps — Backlog.md keeps no status history.';
1062
+ function kpiTile(opts) {
1063
+ var node = el('div', 'kpi');
1064
+ node.setAttribute('data-tip', APPROX_TIP);
1065
+ node.appendChild(el('span', 'kpi-big', opts.big));
1066
+ node.appendChild(el('span', 'kpi-cap', opts.cap));
1067
+ if (opts.sub) {
1068
+ var sub = el('span', 'kpi-sub', opts.sub);
1069
+ if (opts.tone) sub.setAttribute('data-tone', opts.tone);
1070
+ node.appendChild(sub);
1071
+ }
1072
+ return node;
1073
+ }
1074
+ function wipBlockedTile(k) {
1075
+ var node = el('div', 'kpi');
1076
+ node.setAttribute('data-tip', APPROX_TIP);
1077
+ var wipBtn = el('button', 'kpi-special-btn');
1078
+ wipBtn.type = 'button';
1079
+ wipBtn.setAttribute('data-special', 'wip');
1080
+ wipBtn.setAttribute('aria-pressed', 'false');
1081
+ wipBtn.addEventListener('click', function () { setSpecialFilter('wip'); });
1082
+ wipBtn.appendChild(el('span', 'kpi-big', String(k.wip)));
1083
+ wipBtn.appendChild(el('span', 'kpi-cap', 'in progress'));
1084
+ node.appendChild(wipBtn);
1085
+ var blockedBtn = el('button', 'kpi-special-btn');
1086
+ blockedBtn.type = 'button';
1087
+ blockedBtn.setAttribute('data-special', 'blocked');
1088
+ blockedBtn.setAttribute('aria-pressed', 'false');
1089
+ blockedBtn.addEventListener('click', function () { setSpecialFilter('blocked'); });
1090
+ var sub = el('span', 'kpi-sub', k.blocked + ' blocked · click to filter');
1091
+ if (k.blocked > 0) sub.setAttribute('data-tone', 'danger');
1092
+ blockedBtn.appendChild(sub);
1093
+ node.appendChild(blockedBtn);
1094
+ return node;
1095
+ }
1096
+ function renderKpis(mount, k) {
1097
+ mount.textContent = '';
1098
+ if (!k) return;
1099
+ var grid = el('div', 'kpi-grid');
1100
+ grid.appendChild(kpiTile({
1101
+ big: k.progressPct + '%', cap: 'complete',
1102
+ sub: k.done + ' / ' + k.total + ' done' + (k.forecastDate ? ' · at this pace done ~' + k.forecastDate : ''),
1103
+ }));
1104
+ var delta = k.velocity7 - k.velocityPrev7;
1105
+ grid.appendChild(kpiTile({
1106
+ big: String(k.velocity7), cap: 'done / last 7 days',
1107
+ sub: delta === 0 ? '± 0 vs previous week' : (delta > 0 ? '▲ +' : '▼ ') + delta + ' vs previous week',
1108
+ tone: delta > 0 ? 'ok' : delta < 0 ? 'danger' : undefined,
1109
+ }));
1110
+ grid.appendChild(wipBlockedTile(k));
1111
+ var age = kpiTile({
1112
+ big: k.oldestOpenDays === null ? '—' : k.oldestOpenDays + 'd', cap: 'oldest open task',
1113
+ sub: k.medianOpenAgeDays === null ? '' : 'median ' + k.medianOpenAgeDays + 'd open',
1114
+ });
1115
+ if (k.oldestOpenId) {
1116
+ var link = el('button', 'kpi-link', k.oldestOpenId);
1117
+ link.type = 'button';
1118
+ link.addEventListener('click', function () { openDetail(k.oldestOpenId); });
1119
+ age.appendChild(link);
1120
+ }
1121
+ grid.appendChild(age);
1122
+ mount.appendChild(grid);
1123
+ }
1124
+ function ageInDays(task) {
1125
+ var raw = task.created || task.updated;
1126
+ if (!raw) return null;
1127
+ var t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw + 'T12:00:00' : raw);
1128
+ if (isNaN(t)) return null;
1129
+ return Math.max(0, Math.round((Date.now() - t) / 86400000));
1130
+ }
1131
+ var STALE_DAYS = 14;
1132
+ function renderAging(mount, tasks) {
1133
+ mount.textContent = '';
1134
+ var lanes = [];
1135
+ var byStatus = {};
1136
+ tasks.forEach(function (t) {
1137
+ if (isDoneStatus(t.status)) return;
1138
+ var days = ageInDays(t);
1139
+ if (days === null) return;
1140
+ if (!byStatus[t.status]) { byStatus[t.status] = []; lanes.push(t.status); }
1141
+ byStatus[t.status].push({ task: t, days: days });
1142
+ });
1143
+ if (lanes.length === 0) {
1144
+ mount.appendChild(el('p', 'hint', 'No datable open tasks.'));
1145
+ return;
1146
+ }
1147
+ var maxDays = 1;
1148
+ lanes.forEach(function (s) { byStatus[s].forEach(function (e) { if (e.days > maxDays) maxDays = e.days; }); });
1149
+ var labelW = 130, w = 780, laneH = 34, padR = 20;
1150
+ var h = lanes.length * laneH + 24;
1151
+ var svg = svgEl('svg', { viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'aging', role: 'img', 'aria-label': 'Open tasks by age in days' });
1152
+ lanes.forEach(function (s, i) {
1153
+ var y = i * laneH + 22;
1154
+ var lbl = svgEl('text', { x: labelW - 12, y: y + 4, 'text-anchor': 'end', 'class': 'aging-lane-label' });
1155
+ lbl.textContent = s;
1156
+ svg.appendChild(lbl);
1157
+ svg.appendChild(svgEl('line', { x1: labelW, y1: y, x2: w - padR, y2: y, 'class': 'aging-track' }));
1158
+ byStatus[s].forEach(function (e) {
1159
+ var cx = labelW + (w - labelW - padR) * e.days / maxDays;
1160
+ var dot = svgEl('circle', {
1161
+ cx: cx.toFixed(1), cy: y, r: 6, 'class': 'age-dot',
1162
+ fill: e.days >= STALE_DAYS ? 'var(--warn)' : TONE_VAR[toneOf(s)]
1163
+ });
1164
+ attachTitle(dot, e.task.id + ' · ' + e.task.title + ' · ' + e.days + 'd');
1165
+ dot.addEventListener('click', function () { openDetail(e.task.id); });
1166
+ svg.appendChild(dot);
1167
+ });
1168
+ });
1169
+ var axis0 = svgEl('text', { x: labelW, y: h - 4, 'class': 'aging-axis' });
1170
+ axis0.textContent = '0d';
1171
+ var axisMax = svgEl('text', { x: w - padR, y: h - 4, 'text-anchor': 'end', 'class': 'aging-axis' });
1172
+ axisMax.textContent = maxDays + 'd';
1173
+ svg.appendChild(axis0);
1174
+ svg.appendChild(axisMax);
1175
+ mount.appendChild(svg);
1176
+ }
1177
+
757
1178
  function renderBars(mount, milestones) {
758
1179
  mount.textContent = '';
759
1180
  if (milestones.length === 0) {
@@ -789,44 +1210,95 @@
789
1210
  mount.appendChild(svg);
790
1211
  }
791
1212
 
792
- function renderSparkline(mount, activity) {
1213
+ function weekdayName(idx, style) {
1214
+ /* 2024-01-07 was a Sunday; offset from it gives any weekday */
1215
+ var ref = new Date(Date.UTC(2024, 0, 7 + idx));
1216
+ try {
1217
+ return new Intl.DateTimeFormat(undefined, { weekday: style, timeZone: 'UTC' }).format(ref);
1218
+ } catch (e) {
1219
+ return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][idx];
1220
+ }
1221
+ }
1222
+ function renderActivityKpis(mount, k) {
793
1223
  mount.textContent = '';
794
- if (!activity || activity.length < 2) {
1224
+ if (!k) return;
1225
+ var grid = el('div', 'kpi-grid');
1226
+ grid.appendChild(kpiTile({ big: String(k.activityTotal30), cap: 'tasks touched / 30 days' }));
1227
+ grid.appendChild(kpiTile({ big: String(k.activityAvgPerWeek), cap: 'avg per week (26w)' }));
1228
+ grid.appendChild(kpiTile({
1229
+ big: k.busiestWeekday === null ? '—' : weekdayName(k.busiestWeekday, 'long'),
1230
+ cap: 'most active day'
1231
+ }));
1232
+ grid.appendChild(kpiTile({ big: k.streakDays + 'd', cap: 'current streak' }));
1233
+ mount.appendChild(grid);
1234
+ }
1235
+ function renderDayPanel(panel, bucket) {
1236
+ panel.textContent = '';
1237
+ panel.hidden = false;
1238
+ var head = el('h4', '', bucket.date + ' — ' + bucket.count + ' task' + (bucket.count === 1 ? '' : 's'));
1239
+ panel.appendChild(head);
1240
+ var list = el('ul', 'flow-list');
1241
+ var shown = 0;
1242
+ bucket.ids.forEach(function (id) {
1243
+ var t = findTask(id);
1244
+ if (!t) return;
1245
+ shown++;
1246
+ var li = el('li', 'flow-card');
1247
+ var h = el('div', 'flow-card-head');
1248
+ h.appendChild(el('span', 'flow-card-id', t.id));
1249
+ var chip = el('span', 'status-chip', t.status);
1250
+ chip.setAttribute('data-tone', toneOf(t.status));
1251
+ h.appendChild(chip);
1252
+ li.appendChild(h);
1253
+ li.appendChild(el('div', 'flow-card-title', t.title));
1254
+ li.addEventListener('click', function () { openDetail(t.id); });
1255
+ list.appendChild(li);
1256
+ });
1257
+ if (shown === 0) panel.appendChild(el('p', 'flow-empty', 'No task details for this day.'));
1258
+ else panel.appendChild(list);
1259
+ }
1260
+ function renderHeatmap(mount, activity) {
1261
+ mount.textContent = '';
1262
+ if (!activity || activity.length === 0) {
795
1263
  mount.appendChild(el('p', 'hint', 'No activity data.'));
796
1264
  return;
797
1265
  }
798
- var w = 780, h = 150, padL = 14, padR = 14, padT = 18, padB = 26;
1266
+ var panel = document.getElementById('day-panel');
1267
+ var cell = 12, gap = 3, padL = 34, padT = 16;
1268
+ var first = new Date(activity[0].date + 'T00:00:00Z');
1269
+ var offset = first.getUTCDay();
1270
+ var cols = Math.ceil((activity.length + offset) / 7);
1271
+ var w = padL + cols * (cell + gap);
1272
+ var h = padT + 7 * (cell + gap) + 14;
799
1273
  var max = 1;
800
1274
  activity.forEach(function (b) { if (b.count > max) max = b.count; });
801
- function px(i) { return padL + (w - padL - padR) * i / (activity.length - 1); }
802
- function py(c) { return padT + (h - padT - padB) * (1 - c / max); }
803
- var svg = svgEl('svg', {
804
- viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'spark',
805
- role: 'img', 'aria-label': 'Task activity over the last 30 days'
806
- });
807
- var coords = activity.map(function (b, i) {
808
- return px(i).toFixed(1) + ',' + py(b.count).toFixed(1);
1275
+ var svg = svgEl('svg', { viewBox: '0 0 ' + w + ' ' + h, width: '100%', 'class': 'heatmap', role: 'img', 'aria-label': 'Daily task activity, last 26 weeks' });
1276
+ [1, 3, 5].forEach(function (row) {
1277
+ var lbl = svgEl('text', { x: padL - 6, y: padT + row * (cell + gap) + cell - 2, 'text-anchor': 'end', 'class': 'hm-axis' });
1278
+ lbl.textContent = weekdayName(row, 'short');
1279
+ svg.appendChild(lbl);
809
1280
  });
810
- var areaD = 'M' + px(0).toFixed(1) + ',' + (h - padB) +
811
- ' L ' + coords.join(' L ') +
812
- ' L ' + px(activity.length - 1).toFixed(1) + ',' + (h - padB) + ' Z';
813
- svg.appendChild(svgEl('path', { d: areaD, 'class': 'spark-area' }));
814
- svg.appendChild(svgEl('polyline', { points: coords.join(' '), 'class': 'spark-line' }));
1281
+ var lastMonth = '';
815
1282
  activity.forEach(function (b, i) {
816
- var dot = svgEl('circle', {
817
- cx: px(i).toFixed(1), cy: py(b.count).toFixed(1),
818
- r: b.count > 0 ? 3.5 : 2,
819
- 'class': b.count > 0 ? 'spark-dot nonzero' : 'spark-dot'
1283
+ var pos = i + offset;
1284
+ var col = Math.floor(pos / 7), row = pos % 7;
1285
+ var level = b.count === 0 ? 0 : Math.max(1, Math.ceil((b.count / max) * 4));
1286
+ var rect = svgEl('rect', {
1287
+ x: padL + col * (cell + gap), y: padT + row * (cell + gap),
1288
+ width: cell, height: cell, rx: 2.5,
1289
+ 'class': 'hm-cell hm-' + level, 'data-date': b.date
820
1290
  });
821
- attachTitle(dot, b.date + ': ' + b.count);
822
- svg.appendChild(dot);
1291
+ attachTitle(rect, b.date + ': ' + b.count);
1292
+ rect.addEventListener('click', function () { if (panel) renderDayPanel(panel, b); });
1293
+ svg.appendChild(rect);
1294
+ var month = b.date.slice(0, 7);
1295
+ if (row === 0 && month !== lastMonth) {
1296
+ lastMonth = month;
1297
+ var m = svgEl('text', { x: padL + col * (cell + gap), y: padT - 5, 'class': 'hm-axis' });
1298
+ m.textContent = b.date.slice(5, 7);
1299
+ svg.appendChild(m);
1300
+ }
823
1301
  });
824
- var first = svgEl('text', { x: padL, y: h - 7, 'class': 'spark-axis' });
825
- first.textContent = activity[0].date;
826
- var last = svgEl('text', { x: w - padR, y: h - 7, 'text-anchor': 'end', 'class': 'spark-axis' });
827
- last.textContent = activity[activity.length - 1].date;
828
- svg.appendChild(first);
829
- svg.appendChild(last);
830
1302
  mount.appendChild(svg);
831
1303
  }
832
1304
 
@@ -897,9 +1369,12 @@
897
1369
  phases = [];
898
1370
  }
899
1371
  if ($('#donut')) renderDonut($('#donut'), data.statuses);
1372
+ if ($('#kpis')) renderKpis($('#kpis'), data.kpis);
1373
+ if ($('#aging')) renderAging($('#aging'), data.tasks);
900
1374
  if ($('#bars')) renderBars($('#bars'), data.milestones);
901
1375
  if ($('#stepper')) renderStepper($('#stepper'), phases);
902
- if ($('#spark')) renderSparkline($('#spark'), data.activity);
1376
+ if ($('#activity-kpis')) renderActivityKpis($('#activity-kpis'), data.kpis);
1377
+ if ($('#heatmap')) renderHeatmap($('#heatmap'), data.activity);
903
1378
 
904
1379
  /* ---------- Glossary tooltips ---------- */
905
1380
  var glossary = {};
@@ -995,32 +1470,67 @@
995
1470
  return list;
996
1471
  }
997
1472
  function depSection(titleText, ids) {
1473
+ var valid = ids.filter(function (id) { return findTask(id); });
1474
+ if (valid.length === 0) return null;
998
1475
  var block = el('div');
999
1476
  block.appendChild(el('h4', '', titleText));
1000
1477
  var row = el('div', 'dep-row');
1001
- ids.filter(function (id) { return findTask(id); }).forEach(function (id) {
1002
- var btn = el('button', 'dep-link', id);
1478
+ valid.forEach(function (id) {
1479
+ var dep = findTask(id);
1480
+ var btn = el('button', 'dep-link');
1003
1481
  btn.type = 'button';
1482
+ var dot = el('span', 'dep-dot' + (dep && isDoneStatus(dep.status) ? ' done' : ''));
1483
+ dot.setAttribute('aria-hidden', 'true');
1484
+ btn.appendChild(dot);
1485
+ btn.appendChild(document.createTextNode(id));
1004
1486
  btn.addEventListener('click', function () { openDetail(id); });
1005
1487
  row.appendChild(btn);
1006
1488
  });
1007
- if (row.children.length === 0) row.appendChild(el('p', 'detail-empty', 'None'));
1008
1489
  block.appendChild(row);
1009
1490
  return block;
1010
1491
  }
1011
- function metaRow(task) {
1012
- var row = el('div', 'meta-row');
1013
- function item(label, value) {
1014
- var s = el('span');
1015
- s.appendChild(el('span', 'meta-label', label + ': '));
1016
- s.appendChild(document.createTextNode(value || ''));
1017
- return s;
1492
+ var PRIORITY_TONE = { high: 'danger', medium: 'warn', low: 'dim' };
1493
+ function priorityTone(priority) {
1494
+ return PRIORITY_TONE[String(priority || '').toLowerCase()] || '';
1495
+ }
1496
+ function metaGrid(task) {
1497
+ var grid = el('div', 'detail-meta');
1498
+ function cell(label, value, tone) {
1499
+ var c = el('div', 'meta-cell');
1500
+ c.appendChild(el('span', 'meta-label', label));
1501
+ var v = el('span', 'meta-value' + (value ? '' : ' empty'), value || '—');
1502
+ if (value && tone) v.setAttribute('data-tone', tone);
1503
+ c.appendChild(v);
1504
+ return c;
1018
1505
  }
1019
- row.appendChild(item('Milestone', task.milestone));
1020
- row.appendChild(item('Priority', task.priority));
1021
- row.appendChild(item('Assignee', task.assignee));
1022
- row.appendChild(item('Updated', task.updated));
1023
- return row;
1506
+ grid.appendChild(cell('Milestone', task.milestone));
1507
+ grid.appendChild(cell('Priority', task.priority, priorityTone(task.priority)));
1508
+ grid.appendChild(cell('Assignee', task.assignee));
1509
+ grid.appendChild(cell('Updated', task.updated));
1510
+ return grid;
1511
+ }
1512
+ function descParagraphs(text) {
1513
+ var frag = document.createDocumentFragment();
1514
+ String(text).split(/\n\s*\n/).forEach(function (para) {
1515
+ var trimmed = para.trim();
1516
+ if (trimmed !== '') frag.appendChild(el('p', 'detail-desc', trimmed));
1517
+ });
1518
+ return frag;
1519
+ }
1520
+ function acSection(task) {
1521
+ var frag = document.createDocumentFragment();
1522
+ var done = task.acs.filter(function (ac) { return ac.checked; }).length;
1523
+ frag.appendChild(el('h4', '', 'Acceptance criteria'));
1524
+ var progress = el('div', 'ac-progress');
1525
+ progress.appendChild(el('span', 'ac-count', done + ' / ' + task.acs.length + ' done'));
1526
+ var bar = el('div', 'ac-bar');
1527
+ var fill = el('div', 'ac-bar-fill');
1528
+ fill.style.width = task.acs.length > 0 ? Math.round((done / task.acs.length) * 100) + '%' : '0%';
1529
+ bar.appendChild(fill);
1530
+ progress.appendChild(bar);
1531
+ frag.appendChild(progress);
1532
+ frag.appendChild(acList(task));
1533
+ return frag;
1024
1534
  }
1025
1535
  function openDetail(id) {
1026
1536
  var t = findTask(id);
@@ -1037,15 +1547,29 @@
1037
1547
  closeBtn.addEventListener('click', closeDetail);
1038
1548
  head.appendChild(closeBtn);
1039
1549
  content.appendChild(head);
1040
- content.appendChild(el('h3', 'detail-title', t.title));
1041
- content.appendChild(el('p', 'detail-desc', t.description || 'No description.'));
1042
- content.appendChild(metaRow(t));
1043
- if (t.acs.length > 0) {
1044
- content.appendChild(el('h4', '', 'Acceptance criteria'));
1045
- content.appendChild(acList(t));
1550
+ var title = el('h3', 'detail-title', t.title);
1551
+ title.id = 'detail-title-h';
1552
+ dialog.setAttribute('aria-labelledby', 'detail-title-h');
1553
+ content.appendChild(title);
1554
+ if (t.description) {
1555
+ content.appendChild(descParagraphs(t.description));
1556
+ } else {
1557
+ content.appendChild(el('p', 'detail-desc', 'No description.'));
1046
1558
  }
1047
- content.appendChild(depSection('Depends on', depsOut[id] || []));
1048
- content.appendChild(depSection('Needed by', depsIn[id] || []));
1559
+ content.appendChild(metaGrid(t));
1560
+ if (t.acs.length > 0) content.appendChild(acSection(t));
1561
+ var out = depSection('Depends on', depsOut[id] || []);
1562
+ if (out) content.appendChild(out);
1563
+ var inn = depSection('Needed by', depsIn[id] || []);
1564
+ if (inn) content.appendChild(inn);
1565
+ var cmd = el('button', 'phase-cmd detail-cmd');
1566
+ cmd.type = 'button';
1567
+ /* backlog task ids are TASK-<n>; other prefixes fall back to the raw id */
1568
+ var cmdLine = 'backlog task edit ' + t.id.replace(/^task-/i, '');
1569
+ cmd.appendChild(el('span', 'cmd-line', cmdLine));
1570
+ cmd.appendChild(el('span', 'cmd-title', 'copy'));
1571
+ cmd.addEventListener('click', function () { copyCommand(cmd, cmdLine); });
1572
+ content.appendChild(cmd);
1049
1573
  dialog.textContent = '';
1050
1574
  dialog.appendChild(content);
1051
1575
  dialog.showModal();
@@ -1053,6 +1577,76 @@
1053
1577
  window.__sblOpenDetail = openDetail;
1054
1578
  if (dialog) dialog.addEventListener('click', function (ev) { if (ev.target === dialog) closeDetail(); });
1055
1579
 
1580
+ /* ---------- Drafts ---------- */
1581
+ function draftMetaGrid(d) {
1582
+ var grid = el('div', 'detail-meta');
1583
+ function cell(label, value, tone) {
1584
+ var c = el('div', 'meta-cell');
1585
+ c.appendChild(el('span', 'meta-label', label));
1586
+ var v = el('span', 'meta-value' + (value ? '' : ' empty'), value || '—');
1587
+ if (value && tone) v.setAttribute('data-tone', tone);
1588
+ c.appendChild(v);
1589
+ return c;
1590
+ }
1591
+ grid.appendChild(cell('Priority', d.priority, priorityTone(d.priority)));
1592
+ grid.appendChild(cell('Assignee', d.assignee));
1593
+ grid.appendChild(cell('Created', d.created));
1594
+ grid.appendChild(cell('Updated', d.updated));
1595
+ return grid;
1596
+ }
1597
+ function openDraftDetail(d) {
1598
+ if (!dialog) return;
1599
+ var content = el('div', 'dialog-content');
1600
+ var head = el('div', 'detail-head');
1601
+ head.appendChild(el('span', 'detail-id', d.id));
1602
+ var chip = el('span', 'status-chip', d.status);
1603
+ chip.setAttribute('data-tone', toneOf(d.status));
1604
+ head.appendChild(chip);
1605
+ var closeBtn = el('button', 'detail-close', '×');
1606
+ closeBtn.type = 'button';
1607
+ closeBtn.setAttribute('aria-label', 'Close details');
1608
+ closeBtn.addEventListener('click', closeDetail);
1609
+ head.appendChild(closeBtn);
1610
+ content.appendChild(head);
1611
+ var title = el('h3', 'detail-title', d.title);
1612
+ title.id = 'detail-title-h';
1613
+ dialog.setAttribute('aria-labelledby', 'detail-title-h');
1614
+ content.appendChild(title);
1615
+ if (d.description) content.appendChild(descParagraphs(d.description));
1616
+ else content.appendChild(el('p', 'detail-desc', 'No description.'));
1617
+ content.appendChild(draftMetaGrid(d));
1618
+ if (d.acs && d.acs.length > 0) content.appendChild(acSection(d));
1619
+ var cmd = el('button', 'phase-cmd detail-cmd');
1620
+ cmd.type = 'button';
1621
+ var cmdLine = 'backlog draft promote ' + d.id.replace(/^task-/i, '');
1622
+ cmd.appendChild(el('span', 'cmd-line', cmdLine));
1623
+ cmd.appendChild(el('span', 'cmd-title', 'copy'));
1624
+ cmd.addEventListener('click', function () { copyCommand(cmd, cmdLine); });
1625
+ content.appendChild(cmd);
1626
+ dialog.textContent = '';
1627
+ dialog.appendChild(content);
1628
+ dialog.showModal();
1629
+ }
1630
+ function renderDrafts(drafts) {
1631
+ var list = document.getElementById('drafts-list');
1632
+ if (!list) return;
1633
+ list.textContent = '';
1634
+ if (!drafts || drafts.length === 0) { list.appendChild(el('li', '', 'No drafts.')); return; }
1635
+ drafts.forEach(function (d) {
1636
+ var li = el('li', 'flow-card');
1637
+ var head = el('div', 'flow-card-head');
1638
+ head.appendChild(el('span', 'flow-card-id', d.id));
1639
+ var chip = el('span', 'status-chip', d.status);
1640
+ chip.setAttribute('data-tone', toneOf(d.status));
1641
+ head.appendChild(chip);
1642
+ li.appendChild(head);
1643
+ li.appendChild(el('div', 'flow-card-title', d.title));
1644
+ li.addEventListener('click', function () { openDraftDetail(d); });
1645
+ list.appendChild(li);
1646
+ });
1647
+ }
1648
+ renderDrafts(data.drafts);
1649
+
1056
1650
  /* ---------- Keyboard shortcuts ---------- */
1057
1651
  document.addEventListener('keydown', function (ev) {
1058
1652
  if (ev.key === 'Escape') {