k0-board 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +361 -1
  21. package/README.md +931 -111
  22. package/bin/k0-board.js +79 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +30 -5
  26. package/platform/darwin/terminal.js +230 -56
  27. package/platform/index.js +2 -1
  28. package/platform/linux/index.js +3 -0
  29. package/platform/linux/tray.py +10 -10
  30. package/platform/shared/command.js +19 -3
  31. package/platform/shared/tmux.js +32 -14
  32. package/platform/win32/index.js +5 -2
  33. package/platform/win32/terminal.js +24 -8
  34. package/platform/win32/tray.ps1 +13 -13
  35. package/server/backlog.js +618 -0
  36. package/server/changelog.js +13 -13
  37. package/server/db.js +1800 -146
  38. package/server/files.js +60 -21
  39. package/server/git.js +53 -14
  40. package/server/idle.js +15 -13
  41. package/server/index.js +1251 -186
  42. package/server/launcher.js +122 -14
  43. package/server/machine.js +10 -1
  44. package/server/mirror.js +1048 -0
  45. package/server/prices.js +59 -0
  46. package/server/projects.js +57 -12
  47. package/server/servers.js +22 -1
  48. package/server/sessions.js +5 -5
  49. package/server/settings.js +25 -2
  50. package/server/update.js +324 -0
  51. package/server/usage.js +642 -0
  52. package/server/watcher.js +67 -13
  53. package/server/worktree.js +600 -0
  54. package/server/writer.js +98 -26
  55. package/web/awake.js +62 -0
  56. package/web/base.css +4 -0
  57. package/web/board.css +396 -16
  58. package/web/board.js +1182 -152
  59. package/web/changelog.js +3 -3
  60. package/web/files.js +73 -16
  61. package/web/index.html +109 -12
  62. package/web/list.css +536 -0
  63. package/web/list.js +1015 -0
  64. package/web/mentions.js +27 -9
  65. package/web/recency.js +10 -10
  66. package/web/units.js +1 -1
  67. package/web/usage.css +169 -0
  68. package/web/usage.html +78 -0
  69. package/web/usage.js +447 -0
  70. package/web/view.js +22 -3
  71. package/web/whatsnew.css +211 -0
  72. package/web/whatsnew.html +49 -0
  73. package/web/whatsnew.js +339 -0
package/web/list.js ADDED
@@ -0,0 +1,1015 @@
1
+ import { render, esc } from '/md.js'
2
+
3
+ // ── The list ─────────────────────────────────────────────────────────────────
4
+ // The dashboard's other shape. The kanban is for working — one note at a time, the one in front
5
+ // of you; this is for planning — everything at once, in the order you put it in, three levels
6
+ // deep and as many repositories as you have.
7
+ //
8
+ // Three things decide how it is built.
9
+ //
10
+ // It shows the same stories the board is showing. The state pills, the repository and the epic
11
+ // lane are applied before anything gets here, so switching between the two views never changes
12
+ // what is in scope — only its shape. That is why `drawList` is handed a list of stories and never
13
+ // goes and asks for one.
14
+ //
15
+ // Nothing is worked out here that the server already knows. Whether a story is blocked, how far
16
+ // an epic has got, and above all WHAT TO DO NEXT — all of it arrives decided. The button
17
+ // at the end of a row draws the answer `server/backlog.js` gave; it does not have an opinion.
18
+ //
19
+ // And every call it makes says so when it is refused. A button that goes quietly dead is worse
20
+ // than no button, because the story it did nothing to still looks like it moved.
21
+
22
+ // What board.js lends this view: the fetch that throws readable errors, the toast, the redraw, the
23
+ // epic hue, the flag glyph, and the two things only the board knows how to do — do what the next
24
+ // step says, and close a story. Handed in rather than imported, so this file has no way to reach
25
+ // back into the board and the two cannot quietly grow into one.
26
+ let hooks = null
27
+
28
+ // `stories` is what is on screen, after the pills, the repository and the epic lane have had
29
+ // their say. `all` is every story the board knows, and it is here for one reason: a drag rewrites
30
+ // the order of a whole group, and a group half of which is hidden behind a filter would come back
31
+ // renumbered with the hidden half at the top.
32
+ let last = { stories: [], all: [], epics: [] }
33
+
34
+ // The epic groups that have been closed, by `path|key`. Closed rather than open: a group you have
35
+ // never touched should show what is in it, and remembering the exceptions is what makes that true
36
+ // for an epic that did not exist the last time this list was drawn.
37
+ const folded = new Set(JSON.parse(localStorage.getItem('k0-list-folded') || '[]'))
38
+ const saveFolded = () => localStorage.setItem('k0-list-folded', JSON.stringify([...folded]))
39
+
40
+ // What is open in the panel beside the rows: a story, or an epic, never both. An epic's discussion
41
+ // is the same kind of reading as a story's, and two panels stacked would be two places to look for
42
+ // one thing.
43
+ let openId = 0
44
+ let openEpicId = 0
45
+ let view = null // the whole of whatever is open, as `/api/backlog/story|epic/:id` gives it
46
+ let drawn = '' // the JSON of what is on screen, so a poll that changed nothing redraws nothing
47
+ let ticker = null
48
+ let ticks = 0
49
+ let beat = ''
50
+
51
+ /**
52
+ * What the board's own answer does not carry: how many decisions are standing, how much of the
53
+ * checklist has passed, whether the last counter-check found something broken.
54
+ *
55
+ * `/api/board` is asked once a second by every open tab and it draws post-its, which have room for
56
+ * none of those; counting them there would be three more queries per story per second for numbers
57
+ * nothing on the board can show. So they are read from `/api/backlog?repo=…`, which counts them
58
+ * already, at the pace the old dense page read them at — and only while this view is on screen.
59
+ *
60
+ * They can be a few seconds behind the rest of the row and that is the trade: a number that lags is
61
+ * worth more than a board that crawls. The story you have actually opened is not affected — its
62
+ * panel re-reads the whole story and puts its own row straight.
63
+ */
64
+ const counts = new Map()
65
+ let countedAt = 0
66
+ let counting = false
67
+
68
+ const COUNT_EVERY = 30000
69
+
70
+ // ── Small change ─────────────────────────────────────────────────────────────
71
+
72
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
73
+
74
+ /** The shape every time here takes. No seconds: nothing on this page is worth reading that finely. */
75
+ const CLOCK = { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }
76
+
77
+ const when = (t) => (t ? new Date(Number(t)).toLocaleString(undefined, CLOCK) : '')
78
+
79
+ const day = (t) => (t ? new Date(Number(t)).toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : '')
80
+
81
+ /** A state as a pill. The colours are the board's own, from base.css: nothing new was invented. */
82
+ const pill = (state) => `<span class="pill" data-state="${esc(state)}">${esc(state)}</span>`
83
+
84
+ /**
85
+ * How deep in the tree a story sits, which is how far its title is indented — and, at nought, what
86
+ * makes it a story you can drag rather than a task belonging to one.
87
+ *
88
+ * Walked over every story the board knows and not over the rows on screen: a task whose parent has
89
+ * been filtered away is still a task, and drawing it flush with the stories would say it was not.
90
+ * `seen` is the guard against a pair of rows each naming the other as parent — a shape `.k0/` is
91
+ * hand-edited into now and then, and one that would otherwise spin here forever.
92
+ */
93
+ function depth(s) {
94
+ const seen = new Set()
95
+ let at = s?.parent_story_id ?? null
96
+ let deep = 0
97
+ while (at && !seen.has(at)) {
98
+ seen.add(at)
99
+ deep++
100
+ at = parents.get(at) ?? null
101
+ }
102
+ return deep
103
+ }
104
+
105
+ /** Every story's parent, for `depth`, rebuilt whenever the board hands a new set of rows over. */
106
+ let parents = new Map()
107
+
108
+ /** The story's counted facts, from whichever of the two answers arrived last. */
109
+ const countsOf = (s) => counts.get(s.id) ?? s
110
+
111
+ // ── The groups ───────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * The rows, in the three levels they are drawn in: repository, epic, story.
115
+ *
116
+ * The order inside a group is the one the rows arrived in, which is the order the server put them
117
+ * in — `sort_hint`, then id — with the tasks falling in directly under the story they belong to.
118
+ * It is also the order a drag rewrites, so what you see and what you are moving are the same list.
119
+ */
120
+ function grouped(stories, epics) {
121
+ const repos = new Map()
122
+ const of = (path, name) => {
123
+ if (!repos.has(path)) repos.set(path, { path, name, groups: new Map(), loose: [] })
124
+ return repos.get(path)
125
+ }
126
+ // Every repository with an epic gets a heading whether or not a story of that epic is on screen:
127
+ // an epic being argued out in a terminal has no stories yet, and it is exactly the one you are
128
+ // watching. The stories bring in the rest.
129
+ for (const e of epics) of(e.project_path, e.project_name).groups.set(e.key, { epic: e, stories: [] })
130
+ for (const s of stories) {
131
+ const repo = of(s.project_path, s.project_name)
132
+ if (!s.epic_key) {
133
+ repo.loose.push(s)
134
+ continue
135
+ }
136
+ // A story that names an epic the list was not handed one for still belongs to it, and the
137
+ // heading is written from what the story itself carries. Dropping it in with the ones that
138
+ // have no epic would say something about it that is not true — and it is exactly the name you
139
+ // came to the list to read.
140
+ if (!repo.groups.has(s.epic_key)) {
141
+ const stub = { key: s.epic_key, title: s.epic_title ?? '', project_path: s.project_path }
142
+ repo.groups.set(s.epic_key, { epic: stub, stories: [] })
143
+ }
144
+ repo.groups.get(s.epic_key).stories.push(s)
145
+ }
146
+ return [...repos.values()]
147
+ .map((r) => ({ ...r, groups: [...r.groups.values()], loose: r.loose }))
148
+ // Only what has something in it. A repository whose every story is filtered away is a heading
149
+ // over nothing, and a dozen of those is the list saying "no" a dozen times.
150
+ .filter((r) => r.loose.length || r.groups.some((g) => g.stories.length || g.epic))
151
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
152
+ }
153
+
154
+ const groupKey = (path, key) => `${path}|${key ?? ''}`
155
+
156
+ /**
157
+ * How tall the scroller is, and how far down the window it starts — written onto the root as
158
+ * `--port` and `--head` so the stylesheet can have the two numbers it cannot work out for itself.
159
+ *
160
+ * The panel sticks to the top of the view and scrolls on its own, so it has to know the height of
161
+ * what it is sticking inside. Above it are the top bar and, inside an epic, the lane: two elements
162
+ * in another stylesheet, one of which changes height with the width of the window. A number
163
+ * written down here instead would be wrong the first time either of them moved — and wrong in the
164
+ * direction that hides the bottom of the panel below the edge of the screen.
165
+ */
166
+ const port = document.querySelector('#viewport')
167
+ if (port) {
168
+ const measure = () => {
169
+ const r = port.getBoundingClientRect()
170
+ document.documentElement.style.setProperty('--port', `${Math.round(r.height)}px`)
171
+ document.documentElement.style.setProperty('--head', `${Math.round(r.top)}px`)
172
+ }
173
+ new ResizeObserver(measure).observe(port)
174
+ measure()
175
+ }
176
+
177
+ // ── The rows ─────────────────────────────────────────────────────────────────
178
+
179
+ const rowsEl = document.createElement('div')
180
+ rowsEl.className = 'rows'
181
+
182
+ const panelEl = document.createElement('section')
183
+ panelEl.id = 'panel'
184
+ panelEl.hidden = true
185
+
186
+ /**
187
+ * The one thing to do with this story next, as a button.
188
+ *
189
+ * The label, the command and the reason are all the server's — see `nextStep` in
190
+ * `server/backlog.js`. One button and not a row of them: the interface says what to do next and
191
+ * starts it, and a story offering ten things to do is a story offering none.
192
+ *
193
+ * `null` is an answer and the page does not argue with it: a story whose terminal is open, one
194
+ * waiting to be looked at, one that is done. Nothing here invents a label the server did not give,
195
+ * because a row and a post-it that disagree about the same story is the whole failure this was
196
+ * moved to one place to avoid.
197
+ */
198
+ function goHtml(s) {
199
+ const step = s.next_step
200
+ if (!step) return ''
201
+ return `<button type="button" class="go" data-go="${s.id}" title="${esc(step.why ?? '')}">${esc(
202
+ step.label
203
+ )}</button>`
204
+ }
205
+
206
+ function storyRow(s) {
207
+ const c = countsOf(s)
208
+ const broken = c.violations_open > 0
209
+ const decisions = c.decisions_total ? `${c.decisions_open}/${c.decisions_total}` : ''
210
+ const checks = c.checks_total ? `${c.checks_passed}/${c.checks_total}` : ''
211
+ const title = broken
212
+ ? `The last counter-check found ${plural(c.violations_open, 'decision')} broken: ` +
213
+ 'it cannot be finished until that is answered.'
214
+ : s.title
215
+ return `<div class="story${s.id === openId ? ' on' : ''}${broken ? ' broken' : ''}" data-id="${s.id}"
216
+ data-repo="${esc(s.project_path)}"${depth(s) ? '' : ' draggable="true"'} title="${esc(title)}">
217
+ <span class="k">${esc(s.key)}</span>
218
+ <span class="title"><span class="in" style="--deep:${depth(s)}">${esc(s.title)}</span></span>
219
+ <span>${pill(s.state)}</span>
220
+ <span class="flag">${s.starred ? hooks.flag : ''}</span>
221
+ <span class="num${broken ? ' bad' : ''}"
222
+ title="Decisions still standing, of all that were taken">${decisions}</span>
223
+ <span class="num" title="Checks passed, of the whole checklist">${checks}</span>
224
+ ${goHtml(s)}
225
+ </div>`
226
+ }
227
+
228
+ /**
229
+ * An epic: a row that opens and closes, how far it has got, and which round the discussion is on.
230
+ *
231
+ * That last line is the only thing moving on an epic that has not turned into stories yet, which is
232
+ * most of an epic's life — `/k0-epic` runs its rounds before a single story exists. Without it the
233
+ * card said "0 of 0" for as long as anybody was watching.
234
+ */
235
+ function epicRow(e, open, n) {
236
+ // An epic the list was handed knows how far it has got and which round it is on. One written
237
+ // from a story that merely names it knows neither, and says so by leaving the bar out rather
238
+ // than drawing an empty one — and it carries no id, so it folds and does not open a panel.
239
+ const { done, total } = e.progress ?? {}
240
+ const pct = total ? Math.round((done / total) * 100) : 0
241
+ const of = e.round?.estimated_total ? ` of about ${e.round.estimated_total}` : ''
242
+ const round = e.round ? `round ${e.round.n}${of}` : ''
243
+ const known = e.progress != null
244
+ return `<button type="button" class="epic" style="--h:${hooks.hue(e.key)}" data-group="${groupKey(
245
+ e.project_path,
246
+ e.key
247
+ )}"${e.id ? ` data-epic-id="${e.id}"` : ''} data-repo="${esc(e.project_path)}" data-key="${esc(e.key)}"
248
+ aria-expanded="${open}" title="${esc(`${e.key} — click to open it, or drop a story on it to move it here`)}">
249
+ <span class="caret">${open ? '⌄' : '›'}</span>
250
+ <span class="key">${esc(e.key)}</span>
251
+ <span class="name">${esc(e.title)}</span>
252
+ ${
253
+ known
254
+ ? `<span class="prog" role="img" aria-label="${done} of ${total} done"><i style="width:${pct}%"></i></span>
255
+ <span class="count">${done}/${total}</span>`
256
+ : `<span class="count push">${n}</span>`
257
+ }
258
+ ${round ? `<span class="round">${esc(round)}</span>` : ''}
259
+ </button>`
260
+ }
261
+
262
+ /** The group at the end: the stories of this repository that belong to no epic. */
263
+ function looseRow(path, open, n) {
264
+ return `<button type="button" class="epic loose" data-group="${groupKey(path, '')}" data-repo="${esc(path)}"
265
+ data-key="" aria-expanded="${open}" title="Stories with no epic — drop one here to take it out of its epic">
266
+ <span class="caret">${open ? '⌄' : '›'}</span>
267
+ <span class="name">No epic</span>
268
+ <span class="count">${n}</span>
269
+ </button>`
270
+ }
271
+
272
+ function paintRows() {
273
+ // Not while something is in the air. This runs off a poll, and rebuilding the rows under a row
274
+ // being dragged takes the target out from under the pointer half way through the gesture.
275
+ if (dragging) return
276
+ const tree = grouped(last.stories, last.epics)
277
+ if (!tree.length) {
278
+ rowsEl.innerHTML = '<p class="none">Nothing here — or the filters have put it all away.</p>'
279
+ return
280
+ }
281
+ const open = (path, key) => !folded.has(groupKey(path, key))
282
+ rowsEl.innerHTML = tree
283
+ .map((r) => {
284
+ const all = [...r.groups.flatMap((g) => g.stories), ...r.loose]
285
+ const live = all.filter((s) => s.state !== 'Done').length
286
+ const body = [
287
+ ...r.groups.map((g) => {
288
+ const on = open(r.path, g.epic.key)
289
+ return epicRow(g.epic, on, g.stories.length) + (on ? g.stories.map(storyRow).join('') : '')
290
+ }),
291
+ r.loose.length
292
+ ? looseRow(r.path, open(r.path, ''), r.loose.length) +
293
+ (open(r.path, '') ? r.loose.map(storyRow).join('') : '')
294
+ : '',
295
+ ].join('')
296
+ return `<section><div class="repo"><h2>${esc(r.name)}</h2><small>${live} open</small></div>${body}</section>`
297
+ })
298
+ .join('')
299
+ }
300
+
301
+ // ── One story, whole ─────────────────────────────────────────────────────────
302
+ // Everything from here to the end of the panel is the old dense page's drawing, lifted rather than
303
+ // written again. Getting a broken decision to be impossible to miss took a long time, and doing it
304
+ // a second time from memory would have lost exactly the details that do it.
305
+
306
+ /** What the live poll says, and what the first paint says before there has been a poll. */
307
+ const beatOf = (v) => ({
308
+ state: v.story.state,
309
+ round: v.rounds.length ? { n: v.rounds.at(-1).n, estimated_total: v.rounds.at(-1).estimated_total } : null,
310
+ session: v.story.session,
311
+ })
312
+
313
+ /** The same for an epic, which has rounds and a state and never a session. */
314
+ const beatOfEpic = (v) => ({
315
+ state: v.epic.state,
316
+ round: v.rounds.length ? { n: v.rounds.at(-1).n, estimated_total: v.rounds.at(-1).estimated_total } : null,
317
+ session: null,
318
+ })
319
+
320
+ /**
321
+ * The line that moves while somebody is being asked questions in a terminal. "Round 3 of about 8"
322
+ * is the estimate as it stood at that round, and it is meant to move: the discussion recounts what
323
+ * is still open every time round, so the total goes up as often as down.
324
+ */
325
+ function liveHtml(l) {
326
+ const bits = []
327
+ if (l.round) {
328
+ bits.push(`<b>Round ${l.round.n}${l.round.estimated_total ? ` of about ${l.round.estimated_total}` : ''}</b>`)
329
+ }
330
+ bits.push(pill(l.state))
331
+ if (l.session?.status) {
332
+ bits.push(
333
+ `<span class="sess" data-status="${esc(l.session.status)}">${esc(l.session.status)}` +
334
+ `${l.session.alive ? '' : ' · ended'}</span>`
335
+ )
336
+ }
337
+ return bits.join('')
338
+ }
339
+
340
+ /**
341
+ * The counter-check, and it sits directly under the title on every story whether or not anything
342
+ * is wrong. It is why any of this exists: a decision the last run found broken has to be the first
343
+ * thing you see, not something you find by scrolling.
344
+ */
345
+ function counterHtml(v) {
346
+ const latest = v.runs[0] ?? null
347
+ const history = v.runs.length
348
+ ? `<table class="runs"><tr><th>Run</th><th>When</th><th>Kept</th><th>Broken</th><th>N/A</th></tr>${v.runs
349
+ .map(
350
+ (r) =>
351
+ `<tr><td>${r.run}</td><td>${esc(when(r.at))}</td><td>${r.kept}</td>` +
352
+ `<td class="${r.violated ? 'bad' : ''}">${r.violated}</td><td>${r.na}</td></tr>`
353
+ )
354
+ .join('')}</table>`
355
+ : ''
356
+
357
+ if (!v.violations.length) {
358
+ return `<section class="counter clean">
359
+ <h3>Counter-check</h3>
360
+ <p>${
361
+ latest
362
+ ? esc(`Run ${latest.run} held every decision: ${latest.kept} kept, ${latest.na} not applicable, none broken.`)
363
+ : 'Nobody has counter-checked this story yet.'
364
+ }</p>
365
+ ${history}
366
+ </section>`
367
+ }
368
+
369
+ const rows = v.violations
370
+ .map(
371
+ (x) =>
372
+ `<div class="v">
373
+ <div class="what"><b>${esc(x.label)}</b><span>${esc(x.text)}</span></div>
374
+ <div class="verdict">violated</div>
375
+ <div class="ev">${x.evidence ? esc(x.evidence) : '<i>no evidence was written</i>'}</div>
376
+ </div>`
377
+ )
378
+ .join('')
379
+
380
+ return `<section class="counter broken">
381
+ <h3>${esc(`${plural(v.violations.length, 'decision')} broken`)}</h3>
382
+ <div class="verdicts">
383
+ <div class="vhead"><span>What was decided</span><span>The verdict</span><span>The evidence</span></div>
384
+ ${rows}
385
+ </div>
386
+ <p class="rule">Put it right and run the counter-check again, or supersede the decision if it is
387
+ the decision that was wrong. Until then this story cannot be marked Done.</p>
388
+ ${history}
389
+ </section>`
390
+ }
391
+
392
+ function roundHtml(r) {
393
+ return `<article class="round">
394
+ <h4>Round ${r.n}${r.estimated_total ? ` of about ${r.estimated_total}` : ''}<time>${esc(when(r.at))}</time></h4>
395
+ <div class="q prose">${render(r.question || '')}</div>
396
+ ${
397
+ r.answer
398
+ ? `<div class="a prose">${render(r.answer)}</div>`
399
+ : '<p class="a waiting">Asked. Nothing answered yet.</p>'
400
+ }
401
+ </article>`
402
+ }
403
+
404
+ function discussionHtml(v) {
405
+ if (!v.rounds.length && !v.epic_rounds.length) return ''
406
+ const epicPart = v.epic_rounds.length
407
+ ? `<details class="inherited" data-keep="inherited">
408
+ <summary>${esc(
409
+ `${plural(v.epic_rounds.length, 'round')} on ${v.epic?.key ?? 'the epic'}, before this story existed`
410
+ )}</summary>
411
+ ${v.epic_rounds.map(roundHtml).join('')}
412
+ </details>`
413
+ : ''
414
+ return `<section class="block">
415
+ <h3>Discussion</h3>
416
+ ${epicPart}
417
+ ${v.rounds.map(roundHtml).join('') || '<p class="none">This story has not been discussed.</p>'}
418
+ </section>`
419
+ }
420
+
421
+ /** The list itself, so a story's decisions and an epic's own are drawn by one piece of code. */
422
+ function decisionList(decisions, { broken = new Set(), epicKey = null } = {}) {
423
+ const items = decisions
424
+ .map((d) => {
425
+ const bits = []
426
+ if (d.owner === 'epic' && epicKey) bits.push(`inherited from ${esc(epicKey)}`)
427
+ if (d.source && d.source !== 'discussion') bits.push(esc(d.source))
428
+ if (d.superseded_by) bits.push('superseded')
429
+ return `<li class="${d.superseded_by ? 'gone' : ''}${broken.has(d.id) ? ' bad' : ''}${
430
+ d.owner === 'epic' && epicKey ? ' up' : ''
431
+ }"><b>${esc(d.label)}</b><span>${esc(d.text)}</span>${bits.length ? `<em>${bits.join(' · ')}</em>` : ''}</li>`
432
+ })
433
+ .join('')
434
+ return `<ul class="decisions">${items}</ul>`
435
+ }
436
+
437
+ function decisionsHtml(v) {
438
+ if (!v.decisions.length) return ''
439
+ const broken = new Set(v.violations.map((x) => x.decision_id))
440
+ return `<section class="block">
441
+ <h3>Decisions</h3>
442
+ ${decisionList(v.decisions, { broken, epicKey: v.epic?.key ?? 'the epic' })}
443
+ </section>`
444
+ }
445
+
446
+ function checksHtml(v) {
447
+ if (!v.checks.length) return ''
448
+ const mark = { pass: '✓', fail: '✗', skip: '–', todo: '' }
449
+ const items = v.checks
450
+ .map(
451
+ (c) =>
452
+ `<li class="${esc(c.state)}">
453
+ <button type="button" class="tick" data-check="${c.id}" data-state="${esc(c.state)}"
454
+ title="${c.state === 'pass' ? 'Passed — click to put it back' : 'Click when you have seen it work'}"
455
+ aria-pressed="${c.state === 'pass'}">${mark[c.state] ?? ''}</button>
456
+ <span>${esc(c.text)}</span>
457
+ ${c.evidence ? `<em>${esc(c.evidence)}</em>` : ''}
458
+ <i class="by">${esc(c.by)}</i>
459
+ </li>`
460
+ )
461
+ .join('')
462
+ const done = v.checks.filter((c) => c.state === 'pass').length
463
+ return `<section class="block">
464
+ <h3>Checklist<small>${done}/${v.checks.length}</small></h3>
465
+ <ul class="checks">${items}</ul>
466
+ </section>`
467
+ }
468
+
469
+ function logHtml(v) {
470
+ if (!v.log.length && !v.sessions.length) return ''
471
+ const entries = v.log
472
+ .map(
473
+ (l) =>
474
+ `<li><time>${esc(when(l.at))}</time>${
475
+ l.session_id ? `<i>${esc(String(l.session_id).slice(0, 8))}</i>` : ''
476
+ }<div class="prose">${render(l.text || '')}</div></li>`
477
+ )
478
+ .join('')
479
+ const sessions = v.sessions.length
480
+ ? `<p class="sessions">${esc(
481
+ v.sessions
482
+ .map(
483
+ (s) =>
484
+ `${String(s.session_id ?? '—').slice(0, 8)} · ${day(s.started_at)}${
485
+ s.ended_at ? ` → ${day(s.ended_at)}` : ' · still open'
486
+ }`
487
+ )
488
+ .join(' · ')
489
+ )}</p>`
490
+ : ''
491
+ return `<section class="block">
492
+ <h3>Log</h3>
493
+ ${entries ? `<ul class="log">${entries}</ul>` : '<p class="none">Nothing has been written down yet.</p>'}
494
+ ${sessions}
495
+ </section>`
496
+ }
497
+
498
+ /**
499
+ * What this story waits for, and what is waiting for it.
500
+ *
501
+ * The second half is the one nothing else in k0 shows. A post-it can say what a story is waiting
502
+ * for; the reason to finish one story before another is usually what is stuck behind it, and until
503
+ * this existed that was only ever visible from the other end.
504
+ */
505
+ function waitsHtml(v) {
506
+ const keys = (list) =>
507
+ list
508
+ .map(
509
+ (d) =>
510
+ `<button type="button" class="dep${d.state === 'Done' ? '' : ' out'}" data-story="${d.id}" ` +
511
+ `title="${esc(`${d.key} · ${d.title} · ${d.state}`)}">${esc(d.key)}</button>`
512
+ )
513
+ .join('')
514
+ const bits = []
515
+ if (v.story.deps.length) bits.push(`<span>waits on</span>${keys(v.story.deps)}`)
516
+ if (v.blocks?.length) bits.push(`<span>and is waited on by</span>${keys(v.blocks)}`)
517
+ return bits.length ? `<p class="waits">${bits.join('')}</p>` : ''
518
+ }
519
+
520
+ const shutHtml = `<button type="button" class="x" data-shut title="Close" aria-label="Close">
521
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
522
+ <path d="M6 6l12 12M18 6L6 18"/>
523
+ </svg>
524
+ </button>`
525
+
526
+ function storyHtml(v) {
527
+ const s = v.story
528
+ const where = [
529
+ s.project_name,
530
+ s.epic_key ? `${s.epic_key} · ${s.epic_title}` : null,
531
+ s.parent_key ? `under ${s.parent_key}` : null,
532
+ ]
533
+ .filter(Boolean)
534
+ .join(' · ')
535
+ return `<header class="head">
536
+ <div class="who">
537
+ <b class="key">${esc(s.key)}</b>
538
+ <h2>${esc(s.title)}</h2>
539
+ ${s.starred ? `<span class="flag" title="Flagged">${hooks.flag}</span>` : ''}
540
+ </div>
541
+ <p class="where">${esc(where)}</p>
542
+ ${waitsHtml(v)}
543
+ <p class="live" id="live">${liveHtml(beatOf(v))}</p>
544
+ ${shutHtml}
545
+ </header>
546
+ ${counterHtml(v)}
547
+ ${s.body ? `<section class="block"><h3>Why</h3><div class="prose">${render(s.body)}</div></section>` : ''}
548
+ ${discussionHtml(v)}
549
+ ${decisionsHtml(v)}
550
+ ${v.plan ? `<section class="block"><h3>Plan</h3><div class="prose">${render(v.plan)}</div></section>` : ''}
551
+ ${checksHtml(v)}
552
+ ${logHtml(v)}`
553
+ }
554
+
555
+ /**
556
+ * An epic in the same panel a story opens in: why it exists, the rounds it was argued out in, the
557
+ * decisions those rounds settled, and what it turned into.
558
+ *
559
+ * It is here because `/k0-epic` is the long way into the backlog and all of it happens before a
560
+ * single story exists. Without this the whole of that conversation would be invisible while it was
561
+ * happening and stay invisible afterwards.
562
+ */
563
+ function epicPanelHtml(v) {
564
+ const e = v.epic
565
+ const pct = e.progress.total ? Math.round((e.progress.done / e.progress.total) * 100) : 0
566
+ const list = v.stories
567
+ .filter((s) => !s.parent_key)
568
+ .map(
569
+ (s) =>
570
+ `<li><button type="button" data-id="${s.id}"><b>${esc(s.key)}</b>` +
571
+ `<span>${esc(s.title)}</span></button>${pill(s.state)}</li>`
572
+ )
573
+ .join('')
574
+ return `<header class="head">
575
+ <div class="who">
576
+ <b class="key">${esc(e.key)}</b>
577
+ <h2>${esc(e.title)}</h2>
578
+ </div>
579
+ <p class="where">${esc(`${e.project_name} · ${e.progress.done} of ${e.progress.total} done`)}</p>
580
+ <p class="live" id="live">${liveHtml(beatOfEpic(v))}</p>
581
+ ${shutHtml}
582
+ </header>
583
+ <div class="bar" role="img" aria-label="${e.progress.done} of ${e.progress.total} done">
584
+ <i style="width:${pct}%"></i>
585
+ </div>
586
+ ${e.body ? `<section class="block"><h3>Why</h3><div class="prose">${render(e.body)}</div></section>` : ''}
587
+ <section class="block">
588
+ <h3>Discussion</h3>
589
+ ${v.rounds.map(roundHtml).join('') || '<p class="none">This epic has not been discussed yet.</p>'}
590
+ </section>
591
+ ${
592
+ v.decisions.length
593
+ ? `<section class="block"><h3>Decisions</h3>${decisionList(v.decisions)}
594
+ <p class="rule">Every story under this epic is held to these, and counter-checked against
595
+ them one by one. Supersede one here and it changes for all of them at once.</p></section>`
596
+ : ''
597
+ }
598
+ <section class="block">
599
+ <h3>Stories<small>${e.progress.done}/${e.progress.total}</small></h3>
600
+ ${list ? `<ul class="mini">${list}</ul>` : '<p class="none">Nothing under it yet.</p>'}
601
+ </section>
602
+ <section class="block">
603
+ <h3>The whole thing at once</h3>
604
+ <p class="rule">A terminal on <code>/k0-ultracode ${esc(e.key)}</code>: one story at a time,
605
+ agents under it, and the tests and the counter-check before each one is called done.</p>
606
+ <button type="button" class="run" data-epic-run="${e.id}">Ultracode</button>
607
+ </section>`
608
+ }
609
+
610
+ /**
611
+ * Which disclosures the reader has opened, by name.
612
+ *
613
+ * The panel is rebuilt from scratch every time the thing it is showing moves — which is the whole
614
+ * point of the once-a-second poll — and a `<details>` drawn fresh is a `<details>` closed. Without
615
+ * this, the inherited rounds somebody opened to read what the epic had settled shut under them on
616
+ * the very round they were waiting for.
617
+ */
618
+ const opened = new Set()
619
+
620
+ function keepOpen(box) {
621
+ for (const d of box.querySelectorAll('details[data-keep]')) {
622
+ const name = d.dataset.keep
623
+ d.open = opened.has(name)
624
+ d.ontoggle = () => (d.open ? opened.add(name) : opened.delete(name))
625
+ }
626
+ }
627
+
628
+ /** Draws whatever is open, and only when it has actually changed: this runs on a poll. */
629
+ function drawPanel() {
630
+ panelEl.hidden = !openId && !openEpicId
631
+ if (!openId && !openEpicId) {
632
+ panelEl.innerHTML = ''
633
+ drawn = ''
634
+ return
635
+ }
636
+ if (!view) return // the "Reading…" line is already in there; leave it alone
637
+ const same = JSON.stringify(view)
638
+ if (same === drawn) return
639
+ drawn = same
640
+ panelEl.innerHTML = openEpicId ? epicPanelHtml(view) : storyHtml(view)
641
+ keepOpen(panelEl)
642
+ }
643
+
644
+ // ── Following it live ────────────────────────────────────────────────────────
645
+
646
+ /**
647
+ * The cheap round, once a second. It moves the line under the title straight away and re-reads the
648
+ * whole story only when something has moved.
649
+ *
650
+ * The `ticks % 5` is not belt and braces. `…/live` reports the LAST round's number and estimate,
651
+ * and a round is written twice — the question when it is asked, the answer when it comes back —
652
+ * with the same number both times. So an answer landing in a round already announced changes
653
+ * nothing the cheap answer can see, and without this the words would sit there unread until the
654
+ * next question. Five seconds is close enough to watch a conversation by.
655
+ */
656
+ async function tick() {
657
+ const story = openId
658
+ const epic = openEpicId
659
+ if (!story && !epic) return
660
+ let now
661
+ try {
662
+ now = await hooks.api(story ? `/api/backlog/story/${story}/live` : `/api/backlog/epic/${epic}/live`)
663
+ } catch {
664
+ return // the server is restarting, or it has gone: the next round will find out
665
+ }
666
+ if (story !== openId || epic !== openEpicId || !now?.state) return
667
+
668
+ const line = panelEl.querySelector('#live')
669
+ if (line) line.innerHTML = liveHtml(now)
670
+ if (story) freshen(story, now)
671
+
672
+ const s = JSON.stringify(now)
673
+ ticks += 1
674
+ if (s === beat && ticks % 5) return
675
+ beat = s
676
+ await reread()
677
+ }
678
+
679
+ /**
680
+ * The row, brought up to date from the same cheap answer the line under the title just used.
681
+ *
682
+ * The numbers are only touched where the totals are already known. `…/live` says how many decisions
683
+ * are standing and how many checks have passed and never says of how many, so writing them into a
684
+ * row nothing has counted yet would leave `7/` — half a fraction, which the row draws as nothing at
685
+ * all. That row waits for the listing, which is along in half a minute.
686
+ */
687
+ function freshen(id, now) {
688
+ const row = last.stories.find((s) => s.id === id)
689
+ const moved = !!row && row.state !== now.state
690
+ if (moved) row.state = now.state
691
+ const before = counts.get(id)
692
+ const counted =
693
+ !!before && (before.decisions_open !== now.decisions || before.checks_passed !== now.checks_passed)
694
+ if (counted) counts.set(id, { ...before, decisions_open: now.decisions, checks_passed: now.checks_passed })
695
+ if (moved || counted) paintRows()
696
+ }
697
+
698
+ /** Reads back whichever of the two is open, and puts the row it came from in step with it. */
699
+ async function reread() {
700
+ const story = openId
701
+ const epic = openEpicId
702
+ try {
703
+ if (story) {
704
+ const whole = await hooks.api(`/api/backlog/story/${story}`)
705
+ if (story !== openId || !whole?.story) return
706
+ view = whole
707
+ // The story view has just recounted everything the row shows. Keeping the two in step matters
708
+ // more than the round trip it would take to ask the listing for it again.
709
+ counts.set(story, whole.story)
710
+ const row = last.stories.find((s) => s.id === story)
711
+ if (row) Object.assign(row, { state: whole.story.state, starred: whole.story.starred })
712
+ } else if (epic) {
713
+ const whole = await hooks.api(`/api/backlog/epic/${epic}`)
714
+ if (epic !== openEpicId || !whole?.epic) return
715
+ view = whole
716
+ } else return
717
+ drawPanel()
718
+ paintRows()
719
+ } catch {
720
+ /* nothing is lost by waiting a second: the next round asks again */
721
+ }
722
+ }
723
+
724
+ function follow() {
725
+ clearInterval(ticker)
726
+ ticker = null
727
+ ticks = 0
728
+ beat = ''
729
+ if (openId || openEpicId) ticker = setInterval(tick, 1000)
730
+ }
731
+
732
+ // ── Opening and shutting ─────────────────────────────────────────────────────
733
+
734
+ async function openStory(id) {
735
+ const n = Number(id)
736
+ if (!n) return
737
+ openId = n
738
+ openEpicId = 0
739
+ await opening('Reading the story…')
740
+ }
741
+
742
+ async function openEpic(id) {
743
+ const n = Number(id)
744
+ if (!n) return
745
+ openEpicId = n
746
+ openId = 0
747
+ await opening('Reading the epic…')
748
+ }
749
+
750
+ /** The half the two of them share: empty the panel, read the thing, start following it. */
751
+ async function opening(waiting) {
752
+ view = null
753
+ drawn = ''
754
+ panelEl.hidden = false
755
+ panelEl.innerHTML = `<p class="none">${waiting}</p>`
756
+ panelEl.scrollTop = 0
757
+ rowsEl.parentElement?.classList.add('open')
758
+ paintRows()
759
+ await reread()
760
+ follow()
761
+ }
762
+
763
+ function shut() {
764
+ openId = 0
765
+ openEpicId = 0
766
+ view = null
767
+ drawn = ''
768
+ clearInterval(ticker)
769
+ ticker = null
770
+ rowsEl.parentElement?.classList.remove('open')
771
+ drawPanel()
772
+ paintRows()
773
+ }
774
+
775
+ // ── The counted facts ────────────────────────────────────────────────────────
776
+
777
+ /**
778
+ * Fills `counts` from the backlog's own listing, for every repository on screen, and no more often
779
+ * than every half minute. It runs beside the board's round rather than inside it: the board is
780
+ * asked once a second and must not wait for this.
781
+ */
782
+ async function ensureCounts() {
783
+ if (counting || Date.now() - countedAt < COUNT_EVERY) return
784
+ const paths = [...new Set(last.stories.map((s) => s.project_path))]
785
+ if (!paths.length) return
786
+ counting = true
787
+ try {
788
+ const answers = await Promise.all(
789
+ paths.map((p) => hooks.api(`/api/backlog?repo=${encodeURIComponent(p)}`).catch(() => null))
790
+ )
791
+ for (const a of answers) for (const s of a?.stories ?? []) counts.set(s.id, s)
792
+ countedAt = Date.now()
793
+ paintRows()
794
+ } finally {
795
+ counting = false
796
+ }
797
+ }
798
+
799
+ // ── Dragging ─────────────────────────────────────────────────────────────────
800
+ // The two things the interface could not do: put a story somewhere else in the order, and move it
801
+ // into another epic. Both are one gesture, and neither changes state — the states are the pills at
802
+ // the top and the road a story walks, not somewhere a piece of paper is dropped.
803
+
804
+ let dragging = null // the row being carried, as `{ id, repo }`
805
+
806
+ const clearMarks = () => {
807
+ for (const el of rowsEl.querySelectorAll('.over')) el.classList.remove('over')
808
+ }
809
+
810
+ /**
811
+ * The top-level stories of one group, in the order they are drawn in.
812
+ *
813
+ * Tasks are not in it and are never dragged: a task's place is under the story it was split out
814
+ * of. Moving one is moving its parent.
815
+ *
816
+ * The order is the one the rows arrived in, which is the server's — `sort_hint`, then id — so the
817
+ * list being renumbered is the same list that was on the screen.
818
+ */
819
+ function groupOf(repo, epicKey) {
820
+ return last.all.filter((s) => s.project_path === repo && (s.epic_key ?? '') === epicKey && !depth(s))
821
+ }
822
+
823
+ /**
824
+ * Where the story has just been dropped, written down.
825
+ *
826
+ * The whole destination group is renumbered rather than a gap being found between two neighbours:
827
+ * every `sort_hint` starts at nought, so on a fresh backlog there is no gap to find. Only the rows
828
+ * whose number really changes are sent, so the second drag in a group costs one request and not
829
+ * eleven.
830
+ *
831
+ * The refusal, whatever it is — a repository gone read-only, a story deleted in another tab — is
832
+ * said out loud. A drag that silently does nothing leaves the row where it was and looks exactly
833
+ * like a drag that was not allowed.
834
+ */
835
+ async function drop(story, epicKey, at) {
836
+ const moving = story.epic_key !== (epicKey || null)
837
+ const list = groupOf(story.project_path, epicKey).filter((s) => s.id !== story.id)
838
+ list.splice(Math.max(0, Math.min(at, list.length)), 0, story)
839
+ const writes = []
840
+ list.forEach((s, i) => {
841
+ const hint = (i + 1) * 10
842
+ const change = {}
843
+ if (s.sort_hint !== hint) change.sort_hint = hint
844
+ if (s.id === story.id && moving) change.epic_key = epicKey || null
845
+ if (Object.keys(change).length) writes.push([s.id, change])
846
+ })
847
+ if (!writes.length) return
848
+ try {
849
+ await Promise.all(
850
+ writes.map(([id, change]) =>
851
+ hooks.api(`/api/backlog/story/${id}`, { method: 'PATCH', body: JSON.stringify(change) })
852
+ )
853
+ )
854
+ } catch (e) {
855
+ hooks.toast(`Couldn't move it: ${e.message}`, 8000)
856
+ }
857
+ hooks.redraw()
858
+ }
859
+
860
+ // ── What you can press ───────────────────────────────────────────────────────
861
+
862
+ rowsEl.onclick = (e) => {
863
+ const go = e.target.closest('[data-go]')
864
+ if (go) {
865
+ e.stopPropagation()
866
+ const story = last.stories.find((s) => s.id === Number(go.dataset.go))
867
+ return story && hooks.next(story)
868
+ }
869
+ const epic = e.target.closest('.epic')
870
+ if (epic) {
871
+ // One click and it does the two things that go together: the group opens, and the epic itself
872
+ // opens beside it — its rounds, its decisions and what it turned into. The loose group has no
873
+ // epic to read, so there it is only the fold.
874
+ const key = epic.dataset.group
875
+ folded.has(key) ? folded.delete(key) : folded.add(key)
876
+ saveFolded()
877
+ paintRows()
878
+ if (epic.dataset.epicId && !folded.has(key)) openEpic(epic.dataset.epicId)
879
+ return
880
+ }
881
+ const row = e.target.closest('.story[data-id]')
882
+ if (!row) return
883
+ // Clicking the row that is already open shuts it. The same gesture in both directions, which is
884
+ // what makes a list you can run down with the mouse rather than one you close between stories.
885
+ if (Number(row.dataset.id) === openId) shut()
886
+ else openStory(row.dataset.id)
887
+ }
888
+
889
+ rowsEl.addEventListener('dragstart', (e) => {
890
+ const row = e.target.closest('.story[draggable="true"]')
891
+ if (!row) return
892
+ dragging = { id: Number(row.dataset.id), repo: row.dataset.repo }
893
+ row.classList.add('dragging')
894
+ e.dataTransfer.effectAllowed = 'move'
895
+ // Nothing reads it — the row is held in `dragging` — but a drag carrying no data at all never
896
+ // starts in Firefox.
897
+ e.dataTransfer.setData('text/plain', row.dataset.id)
898
+ })
899
+
900
+ rowsEl.addEventListener('dragend', (e) => {
901
+ dragging = null
902
+ e.target.closest('.story')?.classList.remove('dragging')
903
+ clearMarks()
904
+ // Whatever the poll wanted to draw while the row was in the air, it can have now.
905
+ paintRows()
906
+ })
907
+
908
+ rowsEl.addEventListener('dragover', (e) => {
909
+ if (!dragging) return
910
+ const target = e.target.closest('.story[draggable], .epic')
911
+ // Only inside the repository it came from. Dropping a story into another repository's epic would
912
+ // be moving work between repositories, which is not something you do by letting go of a piece of
913
+ // paper — and the key it is called by would change under it.
914
+ if (!target || target.dataset.repo !== dragging.repo) return
915
+ if (target.dataset.id && Number(target.dataset.id) === dragging.id) return
916
+ e.preventDefault() // without this the browser refuses the drop, silently
917
+ e.dataTransfer.dropEffect = 'move'
918
+ clearMarks()
919
+ target.classList.add('over')
920
+ })
921
+
922
+ rowsEl.addEventListener('dragleave', (e) => {
923
+ if (!rowsEl.contains(e.relatedTarget)) clearMarks()
924
+ })
925
+
926
+ rowsEl.addEventListener('drop', (e) => {
927
+ e.preventDefault()
928
+ const target = e.target.closest('.story[draggable], .epic')
929
+ const carried = dragging
930
+ clearMarks()
931
+ dragging = null
932
+ if (!carried || !target || target.dataset.repo !== carried.repo) return
933
+ const story = last.stories.find((s) => s.id === carried.id)
934
+ if (!story) return
935
+ // On an epic's own row it goes to the top of that group — you dropped it on the name, not
936
+ // between two rows. On a story it takes that story's place, and the one it displaced goes down.
937
+ if (!target.dataset.id) return drop(story, target.dataset.key ?? '', 0)
938
+ const to = last.stories.find((s) => s.id === Number(target.dataset.id))
939
+ if (!to || depth(to)) return // a task is not a place: it belongs under the story it came from
940
+ const group = groupOf(to.project_path, to.epic_key ?? '')
941
+ drop(story, to.epic_key ?? '', group.findIndex((s) => s.id === to.id))
942
+ })
943
+
944
+ panelEl.onclick = (e) => {
945
+ if (e.target.closest('[data-shut]')) return shut()
946
+ const check = e.target.closest('[data-check]')
947
+ if (check) return tickCheck(check.dataset.check, check.dataset.state)
948
+ // The manager, on the whole epic. It is looked up in what the panel is already showing rather
949
+ // than composed here: the key on the command line has to be the key on the screen.
950
+ const run = e.target.closest('[data-epic-run]')
951
+ if (run && view?.epic?.id === Number(run.dataset.epicRun)) return hooks.epicCommand(view.epic, 'k0-ultracode')
952
+ // A key in the panel — one this story waits for, one waiting for it, or one under the open
953
+ // epic — is the same gesture as a row: it takes you to that story.
954
+ const jump = e.target.closest('[data-story]')
955
+ if (jump) return openStory(jump.dataset.story)
956
+ const story = e.target.closest('[data-id]')
957
+ if (story) return openStory(story.dataset.id)
958
+ }
959
+
960
+ // Escape shuts the panel, which is the way out of anything on this page. Not while you are typing
961
+ // in something: there is a search box on other pages of k0 and this must not learn a habit that
962
+ // eats a keystroke somewhere else.
963
+ document.addEventListener('keydown', (e) => {
964
+ if (e.key === 'Escape' && (openId || openEpicId) && !e.target.closest('input, select, textarea')) shut()
965
+ })
966
+
967
+ /** Ticking a line off the checklist by hand, which is what `by: user` on a check means. */
968
+ async function tickCheck(id, state) {
969
+ try {
970
+ await hooks.api(`/api/backlog/check/${Number(id)}`, {
971
+ method: 'PATCH',
972
+ body: JSON.stringify({ state: state === 'pass' ? 'todo' : 'pass', by: 'user' }),
973
+ })
974
+ await reread()
975
+ } catch (e) {
976
+ hooks.toast(`Couldn't do it: ${e.message}`, 8000)
977
+ }
978
+ }
979
+
980
+ // ── The way in and the way out ───────────────────────────────────────────────
981
+
982
+ /**
983
+ * Draw the list into `#board`.
984
+ *
985
+ * The two halves are elements this module keeps rather than makes, and that `render()` leaves alone
986
+ * while the list is on screen: a panel rebuilt from nothing — or merely taken out of the document
987
+ * and put back — would lose where it was scrolled to and shut every disclosure in it, on a poll
988
+ * that runs once a second while somebody is reading.
989
+ */
990
+ export function drawList(board, data, wiring) {
991
+ hooks = wiring
992
+ last = data
993
+ parents = new Map((data.all ?? []).map((s) => [s.id, s.parent_story_id ?? null]))
994
+ paintRows()
995
+ drawPanel()
996
+ // Put in place once and then left alone. `append` on a child that is already where it should be
997
+ // still takes it out of the document and puts it back, and an element that has been out of the
998
+ // document comes back scrolled to the top: doing that on every poll is the panel jumping to its
999
+ // header once a second while somebody is reading the decisions at the bottom of it.
1000
+ if (rowsEl.parentNode !== board) {
1001
+ board.textContent = ''
1002
+ board.append(rowsEl, panelEl)
1003
+ }
1004
+ board.classList.toggle('open', !!(openId || openEpicId))
1005
+ // Coming back from the board with something still open: pick the poll up again rather than
1006
+ // leaving a panel that has stopped moving in front of a discussion that has not.
1007
+ if ((openId || openEpicId) && !ticker) follow()
1008
+ ensureCounts()
1009
+ }
1010
+
1011
+ /** Leaving the list for the board: whatever was open stops being followed. */
1012
+ export function leaveList() {
1013
+ clearInterval(ticker)
1014
+ ticker = null
1015
+ }