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/board.js CHANGED
@@ -3,6 +3,9 @@ import { initView, refit } from '/view.js'
3
3
  import { weight, gb } from '/units.js'
4
4
  import { setFavicon } from '/favicon.js'
5
5
  import { split } from '/recency.js'
6
+ import { drawList, leaveList } from '/list.js'
7
+ import { search as fuzzy } from '/fuzzy.js'
8
+ import { whileVisible } from '/awake.js'
6
9
 
7
10
  // ── Statuses ───────────────────────────────────────────────────────
8
11
  // The order is the attention priority inside a column: first whoever is waiting for you,
@@ -28,13 +31,35 @@ const $ = (s) => document.querySelector(s)
28
31
  const api = async (url, opts) => {
29
32
  const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
30
33
  const body = await r.json()
31
- if (!r.ok) throw new Error(body.error || 'Something went wrong')
34
+ if (!r.ok) {
35
+ // The refusal carries more than a sentence — the decisions the counter-check found broken
36
+ // come with it — and throwing only `body.error` was how they were lost between the server
37
+ // saying which rule was broken and the board telling the user something was.
38
+ const err = new Error(body.error || 'Something went wrong')
39
+ err.body = body
40
+ throw err
41
+ }
32
42
  return body
33
43
  }
34
44
 
45
+ /**
46
+ * A refusal, with the decisions it is about named in it.
47
+ *
48
+ * `Run 3 found a decision broken` is true and useless on its own: it tells you to put something
49
+ * right without saying what. The labels are `K7·D3` — an epic's key in front when the rule came
50
+ * down from the epic — and they are the same names the panel and the `.k0/` file print, so
51
+ * one of them is enough to find it. This is the only moment k0 talks about a broken decision to
52
+ * somebody who is not already on the page where the evidence is.
53
+ */
54
+ function refusal(e) {
55
+ const broken = e.body?.violations ?? []
56
+ if (!broken.length) return e.message
57
+ return `${e.message}\n\n${broken.map((v) => `${v.label} — ${v.text}`).join('\n')}`
58
+ }
59
+
35
60
  let projects = []
36
- let editing = null // id of the card open in the editor, null = a new one
37
- let editingCard = null // the whole card: Start needs to know whether a session exists already
61
+ let editing = null // id of the story open in the editor, null = a new one
62
+ let editingStory = null // the whole story: Start needs to know whether a session exists already
38
63
  let chosenProject = null
39
64
  let lastSignature = ''
40
65
 
@@ -52,10 +77,38 @@ const saveFolded = () => localStorage.setItem('k0-folded', JSON.stringify([...fo
52
77
  // deliberately not remembered, so that reloading is what tidies the board up.
53
78
  const held = new Set()
54
79
 
80
+ // Whether this board has a backlog behind it. The server holds the switch and says so on every
81
+ // round; with it off the board is the board it has always been, down to the buttons on a post-it.
82
+ let backlogOn = false
83
+
84
+ // Which shape the same stories come in: the board as it has always been, or a list. Remembered
85
+ // like the filters and the folded columns are, because it is the same kind of choice — how you
86
+ // want to look at the board, not what is on it. `kanban` is the default and the only answer the
87
+ // switch gives while the backlog is off.
88
+ //
89
+ // The key is the one the switch has always used, and only `list` is read out of it: a board that
90
+ // was left on the old `state` columns comes back as a kanban rather than as nothing, which is what
91
+ // a stored value nobody recognises would have meant.
92
+ let columnsMean = localStorage.getItem('k0-columns') === 'list' ? 'list' : 'kanban'
93
+ const byList = () => backlogOn && columnsMean === 'list'
94
+
95
+ // Which repository, for both views. Empty means all of them.
96
+ let repoFilter = localStorage.getItem('k0-repo-filter') || ''
97
+
98
+ // The epic the board is standing inside, `{ key, path }`, or null for the whole board. It
99
+ // survives a reload on purpose: going into an epic is where you decided to work, and a page that
100
+ // let go of it on every refresh would make it a place you cannot stay.
101
+ let laneEpic = readLane()
102
+
103
+ // Every epic k0 knows, kept from the last round: the editor's list is drawn from it, and so are
104
+ // the headings in the list view.
105
+ let allEpics = []
106
+
55
107
  // The weight of each session: on to begin with, switched off from the gauge at the top.
56
108
  let showLoad = localStorage.getItem('k0-load') !== '0'
57
109
  let machine = null // how the computer is doing right now
58
- let heaviest = null // the card eating the most, so only that one gets tinted
110
+ let k0 = null // and what k0 itself is costing it
111
+ let heaviest = null // the story eating the most, so only that one gets tinted
59
112
 
60
113
  // ── Render ─────────────────────────────────────────────────────────
61
114
  /** Coarse-grained time: to the second it was only noise. */
@@ -70,64 +123,267 @@ function since(ts, now) {
70
123
  return d === 1 ? 'yesterday' : `${d} days`
71
124
  }
72
125
 
73
- /** A stable tilt: it depends on the id, so a card does not dance on every refresh. */
126
+ /** A stable tilt: it depends on the id, so a story does not dance on every refresh. */
74
127
  const tilt = (id) => (((id * 37) % 5) - 2) * 0.5
75
128
 
76
- function postit(card, now) {
77
- const el = document.createElement('article')
78
- el.className = `postit ${card.status}`
79
- el.dataset.id = card.id
80
- el.style.transform = `rotate(${tilt(card.id)}deg)`
129
+ // ── What the backlog adds to a post-it ─────────────────────────────
130
+ // Four marks and one button. The marks are the name the story is called by, the epic it belongs to,
131
+ // whether it is waiting on something, and the flag; the button is the one thing to do with it next.
132
+ // Everything else about a story — its decisions, its rounds, its plan — is the panel in the list
133
+ // and not a note, and a post-it that tried to carry it would stop being a post-it. They are all
134
+ // drawn only when the server says there is a backlog: with it off the note is the note it has
135
+ // always been, down to the last element.
136
+
137
+ /** The story's name — `K42`, allocated once and never reused, never reassigned, never renamed. */
138
+ const keyMark = (story) => `<span class="key" title="${esc(story.key)}">${esc(story.key)}</span>`
139
+
140
+ /**
141
+ * An epic's colour. Nothing stores one and nothing should: a colour somebody had to choose is one
142
+ * more thing to choose, and it would then have to be kept in step with an epic that can be
143
+ * renamed and moved. The key is a number that never changes, so the hue is worked out from it —
144
+ * by the golden angle, which is what keeps K7 and K8 far apart instead of two shades of one
145
+ * green. Two repositories both have a K7 and both get the same colour; on a board where their
146
+ * notes stand side by side the label says which, and the colour was never the thing that did.
147
+ */
148
+ const epicHue = (key) => Math.round((Number(String(key).replace(/\D/g, '') || 0) * 137.508) % 360)
149
+
150
+ /**
151
+ * The epic, in its colour, and the way into it: click it and the board becomes that epic.
152
+ *
153
+ * Inside that epic it is not drawn at all. Every note in a lane belongs to the same one, so the
154
+ * label would be the same pill repeated down the column saying what the strip at the top already
155
+ * says — and it would be a way in to where you are standing. The foot is the quietest part of the
156
+ * note and it has room for three marks, not for one of them said twice.
157
+ */
158
+ function epicChip(story) {
159
+ if (!story.epic_key) return ''
160
+ if (laneEpic && laneEpic.key === story.epic_key && laneEpic.path === story.project_path) return ''
161
+ const label = `${story.epic_key} ${story.epic_title ?? ''}`.trim()
162
+ return `<button class="epic" style="--h:${epicHue(story.epic_key)}" title="${esc(
163
+ `${label} — click to see only this epic`
164
+ )}">${esc(label)}</button>`
165
+ }
166
+
167
+ /**
168
+ * The one thing to do with this story next, and it is the server that decided it — see `nextStep`
169
+ * in `server/backlog.js`. The note draws the answer and never has one of its own: these are the
170
+ * same rules `/k0-next` reasons over, and a second copy of them in here would be a second answer to
171
+ * one question from the day somebody edited one of the two.
172
+ *
173
+ * One suggestion per note, not a row of them. It goes first, ahead of Start and Done, because it is
174
+ * the thing you are being told to do. It answers whether it drew anything, because that is what
175
+ * decides how loud Start is allowed to be next to it: `null` is an answer here — a story that came
176
+ * through its counter-check clean is waiting for a person to press Done, and filling that in with a
177
+ * command would be inventing work.
178
+ *
179
+ * The one exception is `Ultracode`, drawn beside `Work` and only there. It is not a second
180
+ * suggestion — the server suggests one thing and this is not it — but the same suggestion taken a
181
+ * different way: the work of that story done by a manager with agents under it rather than by hand
182
+ * in one session. That is why it is a link and not a button, and why it exists nowhere else on the
183
+ * note: two roads out of one state are a choice, three would be a menu.
184
+ */
185
+ function nextStepButton(story, btn) {
186
+ const step = story.next_step
187
+ if (!step || !(step.action || step.command)) return false
188
+ btn(step.label, () => doNextStep(story), step.why)
189
+ return true
190
+ }
81
191
 
82
- const alive = card.session_id && card.session_alive && !card.completed_at
83
- const dead = card.session_id && !card.session_alive && !card.completed_at
84
- if (alive) el.title = 'Double-click to bring its terminal up front'
192
+ /**
193
+ * Doing it, wherever it was pressed — the note or a row in the list.
194
+ *
195
+ * The two things a suggestion can be: pick a closed conversation back up, or start a session
196
+ * saying `/k0-plan K42`. A step that is neither is a step from a server newer than this page, and
197
+ * doing nothing is the only honest answer left — guessing would put something on a command line.
198
+ *
199
+ * `/k0-work` asks the same question Quick Start asks, because it is the same act: it is the one
200
+ * command here that goes and does the work, and doing it on a story that waits on something
201
+ * unfinished is the moment the note you left yourself was for. The other commands only talk about
202
+ * the story, and being asked before a discussion would be a question with nothing riding on it.
203
+ */
204
+ async function doNextStep(story) {
205
+ const step = story.next_step
206
+ if (!step) return
207
+ if (step.action === 'resume') return start(story.id, 'resume')
208
+ if (!step.command) return
209
+ if (step.command === 'k0-work' && !(await mayStart(story))) return
210
+ startCommand(story, step.command)
211
+ }
85
212
 
86
- // On a finished card there is nothing left to edit: the bin takes the pencil's place.
87
- const corner = card.completed_at
88
- ? { icon: ICON.del, label: 'Delete', cls: 'corner del', act: () => remove(card) }
89
- : { icon: ICON.edit, label: 'Edit', cls: 'corner', act: () => openEditor(card) }
213
+ /** Whether this story is at the one moment where the manager is offered: the same one `Work` is. */
214
+ const mayUltracode = (story) => story.next_step?.command === 'k0-work'
215
+
216
+ /**
217
+ * The manager, on the story `Work` is being offered on. It asks the same question `Work` asks
218
+ * before it starts, because it is the same act done at a larger size: a story that waits on
219
+ * something unfinished is no better a thing to hand to five agents than to one.
220
+ */
221
+ async function doUltracode(story) {
222
+ if (!(await mayStart(story))) return
223
+ startCommand(story, 'k0-ultracode')
224
+ }
225
+
226
+ /** What a story is still waiting for. Only what is not done: a dependency that closed is history. */
227
+ const waitingOn = (story) => (story.deps ?? []).filter((d) => d.state !== 'Done')
228
+
229
+ /**
230
+ * The mark that says this one waits on something else.
231
+ *
232
+ * It warns and it does not stop: nothing here refuses to start a blocked story, and the button
233
+ * that starts it asks once and then goes ahead. A backlog that will not let you work on what you
234
+ * want is a backlog you stop using — so this is a note to yourself, drawn the size of one.
235
+ */
236
+ function depMark(story) {
237
+ const open = waitingOn(story)
238
+ if (!open.length) return ''
239
+ const list = open.map((d) => `${d.key} ${d.title} (${d.state})`).join(' · ')
240
+ return `<span class="waits" title="${esc(`Waiting on ${list}`)}">${ICON.dep}${
241
+ open.length > 1 ? open.length : ''
242
+ }</span>`
243
+ }
244
+
245
+ /**
246
+ * The flag, at the bottom right, mirroring the pencil at the top.
247
+ *
248
+ * It is a mark and not a target: it is put on from the pencil's dialog, where the rest of the
249
+ * decisions about a story are taken. An empty outline sitting on every note waiting to be clicked
250
+ * was a mark that was everywhere and therefore marked nothing.
251
+ *
252
+ * It is the target too, and it is the only place the flag is set: a checkbox in the dialog meant
253
+ * opening the dialog, ticking a box and saving, for a mark you put on and take off in passing. It
254
+ * is invisible until the pointer is on the note — an outline on every note waiting to be clicked
255
+ * is a mark that is everywhere and therefore marks nothing — and once it is on it stays on, in the
256
+ * warn colour, with the key and the name taking that colour with it. That is the half that does
257
+ * the work: the point of flagging something is to find it again from across the room, and a
258
+ * fifteen-pixel glyph cannot be seen from there.
259
+ */
260
+ const flagMark = (story) => {
261
+ const on = !!story.starred
262
+ const why = on
263
+ ? 'Flagged — click to take it off'
264
+ : 'Flag it: it stands out on the board, and comes first when k0 is asked what to do next'
265
+ return `<button type="button" class="flag${on ? ' on' : ''}" aria-pressed="${on}"
266
+ title="${esc(why)}" aria-label="${esc(why)}">${ICON.flag}</button>`
267
+ }
268
+
269
+ // ── The epic you are standing in ───────────────────────────────────
270
+
271
+ function readLane() {
272
+ try {
273
+ const v = JSON.parse(localStorage.getItem('k0-epic') || 'null')
274
+ return v && v.key && v.path ? { key: v.key, path: v.path } : null
275
+ } catch {
276
+ return null // a lane that cannot be read is no lane: the whole board is the safe answer
277
+ }
278
+ }
279
+
280
+ /** Go into an epic, or come back out of it with null. */
281
+ function setLane(lane) {
282
+ laneEpic = lane
283
+ if (lane) localStorage.setItem('k0-epic', JSON.stringify(lane))
284
+ else localStorage.removeItem('k0-epic')
285
+ lastSignature = ''
286
+ refresh()
287
+ }
288
+
289
+ const enterEpic = (story) => setLane({ key: story.epic_key, path: story.project_path })
290
+
291
+ /**
292
+ * The epic the board is inside, as the server describes it — the title and the progress are its
293
+ * to say, not something worked out here from the notes that happen to be on screen.
294
+ *
295
+ * An epic that is not in the answer any more has been deleted, or its repository has left this
296
+ * machine. Then the way out is taken for you: standing in a lane that no longer exists is an
297
+ * empty board with nothing on it to explain itself.
298
+ */
299
+ function currentEpic(data) {
300
+ if (!backlogOn || !laneEpic) return null
301
+ const found = (data.epics ?? []).find((e) => e.key === laneEpic.key && e.project_path === laneEpic.path)
302
+ if (!found) {
303
+ laneEpic = null
304
+ localStorage.removeItem('k0-epic')
305
+ }
306
+ return found ?? null
307
+ }
308
+
309
+ function postit(story, now) {
310
+ const el = document.createElement('article')
311
+ el.className = `postit ${story.status}${backlogOn && story.starred ? ' flagged' : ''}`
312
+ el.dataset.id = story.id
313
+ el.style.transform = `rotate(${tilt(story.id)}deg)`
314
+
315
+ const alive = story.session_id && story.session_alive && !story.completed_at
316
+ const dead = story.session_id && !story.session_alive && !story.completed_at
317
+ el.title = alive
318
+ ? 'Double-click to bring its terminal up front'
319
+ : story.completed_at
320
+ ? ''
321
+ : story.session_id
322
+ ? 'Double-click to pick the conversation up where it was'
323
+ : 'Double-click to open a session on it'
324
+
325
+ // On a finished story there is nothing left to edit: the bin takes the pencil's place.
326
+ const corner = story.completed_at
327
+ ? { icon: ICON.del, label: 'Delete', cls: 'corner del', act: () => remove(story) }
328
+ : { icon: ICON.edit, label: 'Edit', cls: 'corner', act: () => openEditor(story) }
90
329
 
91
330
  el.innerHTML = `
92
331
  <button class="${corner.cls}" title="${corner.label}" aria-label="${corner.label}">${corner.icon}</button>
93
- <div class="head"><span class="tag">${esc(card.project_name)}</span>${gitChip(
94
- card.git,
332
+ <div class="head">${backlogOn ? keyMark(story) : ''}<span class="tag">${esc(story.project_name)}</span>${gitChip(
333
+ story.git,
95
334
  // A session with a worktree of its own also has files of its own: that is the directory
96
335
  // to open. But only while the worktree really exists — `own` comes from the server,
97
336
  // which has just read that directory's git state.
98
- card.git?.own ? card.work_path : card.project_path,
99
- card.id
100
- )}${loadChip(card)}</div>
101
- <div class="title">${esc(card.title)}</div>
337
+ story.git?.own ? story.work_path : story.project_path,
338
+ story.id
339
+ )}${loadChip(story)}</div>
340
+ <div class="title">${esc(story.title)}</div>
102
341
  ${
103
342
  // Description and prompt travel together in one box, and that box is the only part of the
104
- // card allowed to shrink: whatever the text does, the repository, the title, the age and
343
+ // story allowed to shrink: whatever the text does, the repository, the title, the age and
105
344
  // the buttons stay where they are.
106
- card.description || card.prompt
345
+ story.description || story.prompt
107
346
  ? `<div class="body">
108
- ${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
109
- ${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
347
+ ${story.description ? `<div class="desc">${esc(story.description)}</div>` : ''}
348
+ ${story.prompt ? `<div class="prompt">${esc(story.prompt)}</div>` : ''}
110
349
  </div>`
111
350
  : ''
112
351
  }
113
352
  <div class="foot">
353
+ ${
354
+ // The epic and what the story is waiting for: two marks on the line that was already there,
355
+ // rather than a row of their own. The flag is not among them — it is hung in the corner,
356
+ // where it can be seen without reading the note.
357
+ backlogOn ? `${epicChip(story)}${depMark(story)}` : ''
358
+ }
114
359
  ${
115
360
  // Whoever shut the window, said in the same breath and the same weight — this line is
116
361
  // already only an italic, and it stays one. Which of the two it is does not need its own
117
362
  // place in the redraw signature: `auto_closed` never moves without `session_alive`
118
363
  // moving with it, and that one is in there.
119
- dead ? `<span class="dead">${card.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
364
+ dead ? `<span class="dead">${story.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
120
365
  }
121
- <span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
366
+ <span class="since" title="${LABEL[story.status]} for ${since(story.status_since, now)}">${since(story.status_since, now)}</span>
122
367
  </div>
123
- <div class="actions"></div>`
368
+ <div class="actions"></div>
369
+ ${backlogOn ? flagMark(story) : ''}`
124
370
 
125
- // The only way to open the editor: clicking the card itself no longer does anything.
371
+ // The only way to open the editor: clicking the story itself no longer does anything.
126
372
  el.querySelector('.corner').onclick = (e) => {
127
373
  e.stopPropagation()
128
374
  corner.act()
129
375
  }
130
376
 
377
+ // The one mark in the foot that does something. `stopPropagation` for the same reason the corner
378
+ // has it: the note answers a double click by bringing its terminal up, and this is not a way of
379
+ // asking for that.
380
+ const chip = el.querySelector('.epic')
381
+ if (chip)
382
+ chip.onclick = (e) => {
383
+ e.stopPropagation()
384
+ enterEpic(story)
385
+ }
386
+
131
387
  const actions = el.querySelector('.actions')
132
388
  const btn = (text, fn, title, cls) => {
133
389
  const b = document.createElement('button')
@@ -141,41 +397,81 @@ function postit(card, now) {
141
397
  actions.append(b)
142
398
  }
143
399
 
144
- if (card.completed_at) {
145
- btn('Reopen', () => setCompleted(card.id, false))
146
- } else if (!card.session_id) {
147
- btn('Start', () => start(card.id, 'start'))
400
+ if (story.completed_at) {
401
+ btn('Reopen', () => setCompleted(story.id, false))
402
+ } else if (!story.session_id) {
403
+ // What to do next comes first, and where there is one Quick Start goes to the end of the row as
404
+ // a link — the same place Close takes for the same reason. They are the two roads out and they
405
+ // are not the same road: the suggestion opens a session on `/k0-discuss K42` and takes the long
406
+ // way round, Quick Start opens an empty one and leaves the typing to you. Where there is no
407
+ // suggestion Quick Start is the button it has always been.
408
+ const suggested = backlogOn && nextStepButton(story, btn)
409
+ if (!suggested) btn('Quick Start', () => startStory(story))
410
+ // The manager, next to `Work` and only there: the same story, handed to several agents at once
411
+ // instead of to one session. A link rather than a button because `Work` is still the ordinary
412
+ // road, and this is the one you take on purpose.
413
+ if (suggested && mayUltracode(story))
414
+ btn('Ultracode', () => doUltracode(story), 'hand it to a manager with agents under it', 'link')
415
+ // And Done beside it, once there is a backlog behind the board. A story with no session is not
416
+ // only an idea nobody has touched: it is also the ordinary shape of one planned here and then
417
+ // worked on in a terminal the user opened himself, or checked over by hand. `Done` is the only
418
+ // thing in k0 that closes a story, so without this there is no way to close that one at all —
419
+ // and the backlog's own rule is that any state may follow any other, Backlog to Done included.
420
+ // Switched off, the post-it is exactly the post-it it has always been.
421
+ if (backlogOn) btn('Done', () => setCompleted(story.id, true), 'close this job')
422
+ if (suggested) btn('Quick Start', () => startStory(story), 'open a session on it and type there yourself', 'link')
148
423
  } else {
149
- if (dead) btn('Resume', () => start(card.id, 'resume'))
150
- // An idea in the backlog cannot be "done": it never started.
151
- btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
424
+ // A session that is running needs no button at all: the terminal is where the work is, and a
425
+ // double click on the note goes there. One that has ended is a conversation waiting to be
426
+ // picked up, and that is the suggestion the server gives back — so with a backlog behind the
427
+ // board Resume arrives as the suggestion, and the line under it is only for a board with the
428
+ // backlog switched off, where nothing suggests anything.
429
+ const suggested = backlogOn && nextStepButton(story, btn)
430
+ if (dead && !suggested) btn('Resume', () => start(story.id, 'resume'))
431
+ btn('Done', () => setCompleted(story.id, true), 'close this job and its terminal')
152
432
  // Close gives the memory back without declaring the work over, and it comes after Done, as a
153
433
  // link rather than a button: it is the rarer of the two and should not compete with it. Not
154
434
  // while the session is working — stopping one mid-thought is not memory saved, it is work lost.
155
- if (!dead && !BUSY.has(card.status))
435
+ if (!dead && !BUSY.has(story.status))
156
436
  btn(
157
437
  'Close',
158
- () => closeSession(card.id),
159
- 'stop the session and its terminal, and leave the card where it is',
438
+ () => closeSession(story.id),
439
+ 'stop the session and its terminal, and leave the story where it is',
160
440
  'link'
161
441
  )
162
442
  }
163
443
 
444
+ // The flag: pressed here and nowhere else. `stopPropagation` for the reason the corner and the
445
+ // epic chip have it — the note answers a click of its own, and this is not a way of asking for
446
+ // that.
447
+ const flag = el.querySelector('.flag')
448
+ if (flag) {
449
+ flag.onclick = (e) => {
450
+ e.stopPropagation()
451
+ setFlag(story)
452
+ }
453
+ flag.addEventListener('dblclick', (e) => e.stopPropagation())
454
+ }
455
+
164
456
  // The git mark is a link: a double click on it would open two tabs and bring the terminal to
165
457
  // the front into the bargain. Double clicks do not reach it.
166
458
  el.querySelector('.git')?.addEventListener('dblclick', (e) => e.stopPropagation())
167
459
 
168
- // Double click: back to this session's terminal, wherever it has ended up.
460
+ // Double click: into the work, whatever state the note is in. Its terminal if one is running,
461
+ // the conversation it left behind if there is one, and a fresh session if there is neither — a
462
+ // gesture that does nothing on half the notes is a gesture nobody ends up trusting.
169
463
  el.ondblclick = (e) => {
170
- if (!alive) return
464
+ if (story.completed_at) return
171
465
  e.preventDefault()
172
- focusTerminal(card.id)
466
+ if (alive) return focusTerminal(story.id)
467
+ if (story.session_id) return start(story.id, 'resume')
468
+ startStory(story)
173
469
  }
174
470
  return el
175
471
  }
176
472
 
177
473
  // Few icons, all in the same stroke: the pencil opens the editor, the bin throws away, the
178
- // "+" gives birth to a card. The same "+" as the one in the bar at the top.
474
+ // "+" gives birth to a story. The same "+" as the one in the bar at the top.
179
475
  const ICON = {
180
476
  edit: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
181
477
  del: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>',
@@ -185,16 +481,22 @@ const ICON = {
185
481
  // The dev server: a world, because what is behind it is a site. A meridian and a parallel are
186
482
  // enough to read it at twelve pixels — more lines and it goes back to being a circle.
187
483
  globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><ellipse cx="12" cy="12" rx="4" ry="9"/></svg>',
484
+ // Waiting on something else: a link of a chain, because that is what a dependency is. Broken in
485
+ // the middle, because it is the half that has not arrived that the mark is about.
486
+ dep: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 14l4-4"/><path d="M13.5 7.5l1-1a3.5 3.5 0 1 1 5 5l-1 1"/><path d="M10.5 16.5l-1 1a3.5 3.5 0 1 1-5-5l1-1"/></svg>',
487
+ // Flagged. Filled, and drawn once — there is no empty flag, because the mark is only ever on a
488
+ // note that has one: an outline on every other note is a mark that says nothing.
489
+ flag: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 21V4"/><path d="M6 4.5h11.5l-2.4 3.8 2.4 3.7H6" fill="currentColor"/></svg>',
188
490
  }
189
491
 
190
492
  const esc = (s) =>
191
493
  String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
192
494
 
193
495
  // ── The git mark ───────────────────────────────────────────────────
194
- // One symbol, on the card and above the column: what is needed is to see at a glance whether
496
+ // One symbol, on the story and above the column: what is needed is to see at a glance whether
195
497
  // anything is still outstanding. The rest — which branch, how many files, how many commits, and
196
498
  // how many of those commits belong to this session — is read by hovering over it, the way the
197
- // time at the bottom of a card already works.
499
+ // time at the bottom of a story already works.
198
500
  const GIT_ICON = {
199
501
  // Things that are nowhere yet: the filled dot editors use.
200
502
  dirty: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="5.5"/></svg>',
@@ -223,11 +525,11 @@ function gitDetail(g) {
223
525
  /**
224
526
  * The git mark is also the door to the files: clicking it opens the viewer in another tab, and
225
527
  * the board stays where it is. Where there is no mark — a repository without git, or git that
226
- * has not answered yet — a lens goes in its place, so every card and every column still leads
528
+ * has not answered yet — a lens goes in its place, so every story and every column still leads
227
529
  * in.
228
530
  */
229
- function gitChip(g, repo, card) {
230
- const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${card ? `&card=${card}` : ''}`)
531
+ function gitChip(g, repo, story) {
532
+ const to = esc(`/files.html?repo=${encodeURIComponent(repo)}${story ? `&story=${story}` : ''}`)
231
533
  const open = 'click to see the files'
232
534
  if (!g) return `<a class="git none" href="${to}" target="_blank" title="${open}">${GIT_ICON.files}</a>`
233
535
  const kind = g.dirty > 0 ? 'dirty' : g.unpushed > 0 ? 'push' : 'ok'
@@ -332,15 +634,15 @@ const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}`
332
634
  * what is doing the eating in there — is read by hovering, and it is the answer to the real
333
635
  * question: why does this terminal weigh so much.
334
636
  */
335
- function loadChip(card) {
336
- if (!showLoad || !card.load) return ''
337
- const l = card.load
637
+ function loadChip(story) {
638
+ if (!showLoad || !story.load) return ''
639
+ const l = story.load
338
640
  const bits = l.top.map((t) => `${t.name} ${Math.round(t.rss / 1048576)} MB`)
339
641
  if (l.cpu !== null) bits.push(`${Math.round(l.cpu * 100)}% CPU`)
340
642
  bits.push(`${l.procs} process${l.procs === 1 ? '' : 'es'}`)
341
643
  // Red on one only, and only while the machine is struggling: if they are all red they stop
342
644
  // saying anything, and it is not true that they all need closing.
343
- const hot = machine?.pressure !== 'ok' && card.id === heaviest
645
+ const hot = machine?.pressure !== 'ok' && story.id === heaviest
344
646
  return `<span class="load${hot ? ' hot' : ''}" title="${esc(bits.join(' · '))}">${weight(l.rss)}</span>`
345
647
  }
346
648
 
@@ -348,34 +650,81 @@ function loadChip(card) {
348
650
  const gitSig = (g) => (g ? `${g.dirty}/${g.unpushed}/${g.own ? 1 : 0}/${g.mine ?? ''}` : '')
349
651
 
350
652
  /**
351
- * How long this card has been where it is. Until it has ever changed status there is nothing in
653
+ * How long this story has been where it is. Until it has ever changed status there is nothing in
352
654
  * `session_event` and `status_since` is empty: then it counts from when it came into existence,
353
- * otherwise a card just created would end up at the bottom of its group.
655
+ * otherwise a story just created would end up at the bottom of its group.
354
656
  */
355
657
  const freshness = (c) => c.status_since ?? c.created_at ?? 0
356
658
 
357
659
  /**
358
660
  * The order of attention: most urgent status first, and among equal statuses whichever entered
359
- * it last. It holds between the cards inside a column and — applied to the leading card —
661
+ * it last. It holds between the stories inside a column and — applied to the leading story —
360
662
  * between the columns on the board.
361
663
  */
362
664
  const byAttention = (a, b) =>
363
665
  ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || freshness(b) - freshness(a)
364
666
 
365
667
  function render(data) {
366
- const visible = data.cards.filter((c) => !hidden.has(c.status))
367
668
  const board = $('#board')
368
- board.textContent = ''
369
669
 
370
- const columns = data.columns
670
+ // The list is a page that scrolls; the board is a surface that moves under a fixed window. The
671
+ // class is what takes the one out of the other — see list.css — and it goes on before anything is
672
+ // drawn, so nothing is ever measured in the wrong world.
673
+ const asList = byList()
674
+ // Emptying the board is how the kanban is redrawn, and in list mode it is exactly what must not
675
+ // happen: the list keeps its two halves between redraws (see `drawList`), and an element taken
676
+ // out of the document comes back scrolled to the top — which on a poll that runs once a second
677
+ // is the panel losing the reader's place while they are reading it.
678
+ if (!asList) board.textContent = ''
679
+ board.classList.toggle('list', asList)
680
+ $('#viewport').classList.toggle('list', asList)
681
+ if (!asList) {
682
+ board.classList.remove('open')
683
+ leaveList()
684
+ }
685
+
686
+ // Inside an epic, the board is that epic. Everything below works on `pool` rather than on
687
+ // `data.stories`, so the epic is a lens over the same board and not a second one: the filters
688
+ // still filter, the columns still fold, the notes are the same notes.
689
+ const inEpic = currentEpic(data)
690
+ drawLane(inEpic)
691
+ renderRepoFilter(data, inEpic)
692
+ // The three things that decide what is in scope, applied once and before the fork: the epic lane,
693
+ // the repository, and the status pills. Both views work on what comes out, which is what makes
694
+ // switching between them a change of shape and never a change of what you are looking at.
695
+ const only = inEpic ? '' : repoFilter
696
+ const pool = data.stories.filter(
697
+ (c) =>
698
+ (!inEpic || (c.epic_key === inEpic.key && c.project_path === inEpic.project_path)) &&
699
+ (!only || c.project_path === only)
700
+ )
701
+ const visible = pool.filter((c) => !hidden.has(c.status))
702
+
703
+ if (asList) {
704
+ // The epics go through the same two narrowings the stories did. They are not filtered by status
705
+ // — an epic has none — but one belonging to a repository you have filtered away has no business
706
+ // heading a group of nothing.
707
+ const epics = (data.epics ?? []).filter(
708
+ (e) =>
709
+ (!inEpic || (e.key === inEpic.key && e.project_path === inEpic.project_path)) &&
710
+ (!only || e.project_path === only)
711
+ )
712
+ drawList(board, { stories: visible, all: data.stories, epics }, LIST)
713
+ // No message in the middle here: the list says for itself when there is nothing in it, and it
714
+ // says it where the rows would have been rather than across the whole window.
715
+ showEmpty(false, inEpic)
716
+ return
717
+ }
718
+
719
+ const columns = (inEpic ? data.columns.filter((c) => c.path === inEpic.project_path) : data.columns)
371
720
  .map((col) => ({
372
721
  col,
373
- cards: visible.filter((c) => c.project_path === col.path).sort(byAttention),
722
+ stories: visible.filter((c) => c.project_path === col.path).sort(byAttention),
374
723
  }))
375
- .filter(({ cards }) => cards.length)
724
+ .filter(({ stories }) => stories.length)
376
725
 
377
726
  // In driving mode the columns queue up by attention too: a column is worth as much as its
378
- // leading card, and since the cards are sorted already, that first one is necessarily the most
727
+ // leading story, and since the stories are sorted already, that first one is necessarily the most
379
728
  // urgent it has. One red is enough to bring it left; when that red moves into working, the
380
729
  // column slides right by itself. On a tie the alphabetical order from the server stands,
381
730
  // because `sort` is stable.
@@ -385,32 +734,44 @@ function render(data) {
385
734
  // they are all in front of you already: there is nothing to bring to anybody, and a board that
386
735
  // reshuffles itself while you work only loses your place. With the switch off the columns stand
387
736
  // still in alphabetical order — the server's, which is not touched here.
388
- if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
737
+ if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.stories[0], b.stories[0]))
389
738
 
390
739
  // Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
391
740
  // four rules and for why the day is counted back from your last piece of work rather than from
392
741
  // the clock.
393
- const { open, alive } = split({
394
- paths: columns.map((c) => c.col.path),
395
- cards: data.cards,
396
- folded,
397
- held,
398
- })
742
+ //
743
+ // Inside an epic none of that applies: you went in to look at one thing, and a lane that put
744
+ // half of itself away would be answering a question you did not ask. Nothing is folded, and
745
+ // nothing is remembered either — what you do in a lane must not decide what the board looks
746
+ // like when you come back out of it.
747
+ //
748
+ // The repository filter is the same answer twice over. You asked for one repository; folding it
749
+ // away for having been quiet would leave a board with nothing on it and the reason hidden inside
750
+ // a dropdown, and the way back would be through the `Old` column that is not drawn either.
751
+ const { open, alive } = inEpic || only
752
+ ? { open: columns.map(({ col }) => col.path), alive: new Set() }
753
+ : split({
754
+ paths: columns.map((c) => c.col.path),
755
+ stories: pool,
756
+ folded,
757
+ held,
758
+ })
399
759
  // A column open now is open for the rest of the visit, and a column that is open is not one
400
760
  // you have put away: a session woke up in there, or you went and fetched it back yourself.
401
761
  let forget = false
402
- for (const p of open) {
403
- held.add(p)
404
- if (folded.delete(p)) forget = true
405
- }
762
+ if (!inEpic && !only)
763
+ for (const p of open) {
764
+ held.add(p)
765
+ if (folded.delete(p)) forget = true
766
+ }
406
767
  if (forget) saveFolded()
407
768
 
408
769
  const openSet = new Set(open)
409
- for (const { col, cards } of columns.filter(({ col }) => openSet.has(col.path))) {
770
+ for (const { col, stories } of columns.filter(({ col }) => openSet.has(col.path))) {
410
771
  const wrap = document.createElement('section')
411
772
  wrap.className = 'column'
412
- // The "+" next to the name: a new card on THIS repository, without picking it.
413
- const add = `New card in ${esc(col.name)}`
773
+ // The "+" next to the name: a new story on THIS repository, without picking it.
774
+ const add = `New story in ${esc(col.name)}`
414
775
  // And next to it, the one that puts the column away without waiting for it to go quiet on
415
776
  // its own. It sits there in plain sight next to the `+`, because a button you have to go
416
777
  // hunting for with the pointer is a button nobody knows exists.
@@ -421,11 +782,16 @@ function render(data) {
421
782
  // blinking in and out — and it would have done it on exactly the columns you are working on.
422
783
  // A control that stays where it was and says why it will not move is worth more than one
423
784
  // that is only there when it agrees with you. The title carries the reason.
785
+ //
786
+ // In a lane there is no fold at all. There is one column and it is the epic: putting it away
787
+ // would leave an empty board with a strip at the top saying which epic it is empty of.
424
788
  const live = alive.has(col.path)
425
789
  const away = live ? `${col.name} has something going on: it cannot be put away` : `Put ${col.name} away`
426
- const fold = `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
427
- live ? ' disabled' : ''
428
- }>${ICON.fold}</button>`
790
+ const fold = inEpic
791
+ ? ''
792
+ : `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
793
+ live ? ' disabled' : ''
794
+ }>${ICON.fold}</button>`
429
795
  // Two groups and a name between them: what you press on the left, what tells you how things
430
796
  // stand on the right. The count of post-its that used to sit after the name is gone — it
431
797
  // took up the room the name now uses, and it never answered a question anybody had.
@@ -441,19 +807,138 @@ function render(data) {
441
807
  ICON.plus
442
808
  }</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
443
809
  wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
444
- wrap.querySelector('.fold').onclick = () => putAway(col.path)
810
+ const folder = wrap.querySelector('.fold')
811
+ if (folder) folder.onclick = () => putAway(col.path)
445
812
  const globe = wrap.querySelector('.globe')
446
813
  if (globe) wireGlobe(globe, col.path, col.server)
447
- for (const c of cards) wrap.append(postit(c, data.now))
814
+ for (const c of stories) wrap.append(postit(c, data.now))
448
815
  board.append(wrap)
449
816
  }
450
817
 
451
- board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
452
- board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
453
- $('#empty').style.display = columns.length ? 'none' : 'grid'
818
+ // The two columns on the right are about the repositories that are NOT in front of you, which is
819
+ // a question a lane has already answered — there is one repository in here, and it is this one —
820
+ // and which the repository filter answers the same way.
821
+ if (!inEpic && !only) {
822
+ board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
823
+ board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
824
+ }
825
+ showEmpty(columns.length === 0, inEpic, only)
454
826
  refit() // the board changed size: the view comes back inside its limits
455
827
  }
456
828
 
829
+ /**
830
+ * The line in the middle of an empty board, and what it should say.
831
+ *
832
+ * "Add your first story" is right on a board with nothing on it and wrong everywhere else: inside
833
+ * an epic it is not the dashboard that is empty, and behind a repository filter it is not the
834
+ * dashboard that is being looked at. A message that answers the wrong question sends you looking
835
+ * for a fault that is not there.
836
+ */
837
+ function showEmpty(empty, inEpic, only = '') {
838
+ const el = $('#empty')
839
+ el.style.display = empty ? 'grid' : 'none'
840
+ if (!empty) return
841
+ if (inEpic) el.textContent = `Nothing in ${inEpic.key} yet — or the filters have put it all away.`
842
+ else if (only) el.textContent = `Nothing in ${nameOf(only)} — or the filters have put it all away.`
843
+ else el.textContent = 'The dashboard is empty. Add your first story.'
844
+ }
845
+
846
+ // ── The epic you are standing in ───────────────────────────────────
847
+
848
+ /**
849
+ * The strip under the top bar: which epic, how far it has got, and the way out.
850
+ *
851
+ * The progress is the server's arithmetic and not this page's — done over total, no weighting,
852
+ * counted over the stories of the epic and not over the notes that happen to be on screen. Half
853
+ * of them can be hidden by a filter; that does not make the epic half finished.
854
+ */
855
+ function drawLane(epic) {
856
+ const bar = $('#epic-lane')
857
+ bar.hidden = !epic
858
+ if (!epic) return
859
+ const { done, total } = epic.progress
860
+ bar.style.setProperty('--h', epicHue(epic.key))
861
+ $('#epic-key').textContent = epic.key
862
+ $('#epic-name').textContent = epic.title
863
+ $('#epic-repo').textContent = epic.project_name
864
+ $('#epic-fill').style.width = `${total ? Math.round((done / total) * 100) : 0}%`
865
+ $('#epic-count').textContent = `${done} of ${total} done`
866
+ }
867
+
868
+ /** Everything the repository menu can offer, newest board first. `''` is all of them. */
869
+ let repoOptions = [['', 'All repositories']]
870
+
871
+ /**
872
+ * Which repository both views are showing, when they are showing one.
873
+ *
874
+ * Only the list is kept here; drawing it is `paintRepoList`, which happens when the menu is opened
875
+ * and not on every redraw of the board. The board is asked for once a second, and rebuilding rows
876
+ * under an open menu is how a menu closes itself out from under the pointer.
877
+ */
878
+ function renderRepoFilter(data, inEpic) {
879
+ // Whether it is on the bar at all belongs to `renderColumnsSwitch`, which is the only place that
880
+ // knows all the answers. Here there is only what is in it.
881
+ if (inEpic) return
882
+ // A repository that has no stories left has no column and cannot be filtered to: keeping it
883
+ // would leave the board empty with the reason hidden inside a menu nobody has opened.
884
+ if (repoFilter && !data.columns.some((c) => c.path === repoFilter)) chooseRepo('')
885
+ repoOptions = [['', 'All repositories'], ...data.columns.map((c) => [c.path, c.name])]
886
+ $('#repo-filter-btn').textContent = repoOptions.find(([v]) => v === repoFilter)?.[1] ?? 'All repositories'
887
+ }
888
+
889
+ /** The rows of the repository menu, narrowed by what has been typed into it. */
890
+ function paintRepoList(query = '') {
891
+ const rows = fuzzy(repoOptions, query, ([, label]) => label)
892
+ $('#repo-filter-rows').innerHTML = rows.length
893
+ ? rows
894
+ .map(([v, label]) => `<div role="option" data-repo="${esc(v)}" class="${v === repoFilter ? 'on' : ''}"
895
+ aria-selected="${v === repoFilter}">${esc(label)}</div>`)
896
+ .join('')
897
+ : '<div style="color:var(--muted)">no repository with that name</div>'
898
+ }
899
+
900
+ /** Open or shut, in one place: the arrow on the button and the list itself always agree. */
901
+ function showRepoList(open) {
902
+ $('#repo-filter-list').hidden = !open
903
+ $('#repo-filter-btn').setAttribute('aria-expanded', String(open))
904
+ if (!open) return
905
+ $('#repo-filter-q').value = ''
906
+ paintRepoList('')
907
+ $('#repo-filter-q').focus()
908
+ }
909
+
910
+ function chooseRepo(path) {
911
+ repoFilter = path
912
+ if (path) localStorage.setItem('k0-repo-filter', path)
913
+ else localStorage.removeItem('k0-repo-filter')
914
+ }
915
+
916
+ /**
917
+ * The switch itself: which of the two is lit, and whether it is on the bar at all — and with it the
918
+ * repository filter, which is on the bar exactly when the switch is.
919
+ *
920
+ * Both are hidden from here and not from the redraw because the redraw does not always happen:
921
+ * switching the backlog off changes nothing the board compares, so the dropdown would have stayed
922
+ * on the bar, still filtering, over a board that has no filter left to apply. Inside a lane there
923
+ * is one repository and it was settled by going in, so there is nothing to choose there either.
924
+ */
925
+ function renderColumnsSwitch() {
926
+ $('#columns').hidden = !backlogOn
927
+ const hide = !backlogOn || !!laneEpic
928
+ // Shut before it goes: a menu left open under a hidden element comes back open the next time
929
+ // the element does, over a board nobody asked it to cover.
930
+ if (hide && !$('#repo-filter-list').hidden) showRepoList(false)
931
+ $('#repo-filter').hidden = hide
932
+ // And what the `+` promises. With a backlog behind the board it opens a menu with two things on
933
+ // it, and a button whose label says one of them is a button that lied about the other.
934
+ const plus = backlogOn ? 'New story or epic' : 'New story'
935
+ $('#new-story').title = plus
936
+ $('#new-story').setAttribute('aria-label', plus)
937
+ for (const b of document.querySelectorAll('#columns button')) {
938
+ b.setAttribute('aria-checked', b.dataset.columns === columnsMean ? 'true' : 'false')
939
+ }
940
+ }
941
+
457
942
  /** Put a column away by hand, and let go of the place it was holding for this visit. */
458
943
  function putAway(path) {
459
944
  folded.add(path)
@@ -482,7 +967,7 @@ function bringBack(path) {
482
967
  * rest of the visit.
483
968
  *
484
969
  * It sits next to `Others` and is built the same way, down to the row: a `+`, a name, a git mark
485
- * and nothing else. How many cards are parked in there and how long it has been quiet were both
970
+ * and nothing else. How many stories are parked in there and how long it has been quiet were both
486
971
  * in here once, and both had to go — three numbers on a row you are meant to read at a glance is
487
972
  * two too many, and the answer to either is one click away in the column itself.
488
973
  */
@@ -501,7 +986,7 @@ function oldColumn(columns) {
501
986
  [...columns]
502
987
  .sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
503
988
  .map(({ col }) => {
504
- const add = `New card in ${esc(col.name)}`
989
+ const add = `New story in ${esc(col.name)}`
505
990
  const back = `Bring ${col.name} back onto the board`
506
991
  return `<div class="repo" data-p="${esc(col.path)}">
507
992
  <button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
@@ -523,9 +1008,9 @@ function oldColumn(columns) {
523
1008
 
524
1009
  /**
525
1010
  * The last column: the OTHER repositories, the ones with no column because they have not one
526
- * card between them. Repeating the ones already on the left would be saying the same thing
1011
+ * story between them. Repeating the ones already on the left would be saying the same thing
527
1012
  * twice. Headings only, in the order you last used them. It is there for two things: getting
528
- * into the files of a directory you are not working on, and giving birth to the first card
1013
+ * into the files of a directory you are not working on, and giving birth to the first story
529
1014
  * there without having to find it in a list.
530
1015
  */
531
1016
  function repoColumn(projects, shown) {
@@ -545,7 +1030,7 @@ function repoColumn(projects, shown) {
545
1030
  '</small></h2>' +
546
1031
  others
547
1032
  .map((p) => {
548
- const add = `New card in ${esc(p.name)}`
1033
+ const add = `New story in ${esc(p.name)}`
549
1034
  return `<div class="repo" data-p="${esc(p.path)}">
550
1035
  <button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
551
1036
  <span class="name">${esc(p.name)}</span>${gitChip(p.git, p.path)}
@@ -565,7 +1050,7 @@ function repoColumn(projects, shown) {
565
1050
  * is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
566
1051
  * while it says all is well, amber at the first warning, red when it is critical.
567
1052
  */
568
- function renderMachine(m, cards, idleHours = 0) {
1053
+ function renderMachine(m, stories, idleHours = 0) {
569
1054
  const el = $('#gauge')
570
1055
  if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
571
1056
  el.removeAttribute('hidden')
@@ -577,10 +1062,15 @@ function renderMachine(m, cards, idleHours = 0) {
577
1062
  // chip and nowhere else: this is where the memory is already being talked about, and a thing
578
1063
  // that quietly closes your windows should not be something you have to know about beforehand.
579
1064
  // Its presence is the whole message — switched off, there is nothing here rather than a nought.
580
- const closes = idleHours > 0 ? `<span class="idle">· closes at ${idleHours}h</span>` : ''
581
- el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}${closes}`
582
-
583
- const top = cards.find((c) => c.id === heaviest)
1065
+ const closes = idleHours > 0 ? `<span class="idle">closes at ${idleHours}h</span>` : ''
1066
+ // RAM over CPU rather than side by side, and the closing time under them rather than beside them.
1067
+ // The four mode buttons went two by two to get the bar back onto one row, and this is the other
1068
+ // half of the same move: the chip gives back the width it was taking by going up instead of
1069
+ // along, and "· closes at 12h" written across was a hundred pixels of it on its own. The dot
1070
+ // stays beside the stack, centred on it.
1071
+ el.innerHTML = `<i></i><span class="nums"><span>RAM ${ram}%</span><span>CPU ${cpu}</span>${closes}</span>`
1072
+
1073
+ const top = stories.find((c) => c.id === heaviest)
584
1074
  const lines = [
585
1075
  `${gb(m.mem.used)} of ${gb(m.mem.total)} in use · ${gb(m.swap.used)} on swap`,
586
1076
  `${m.alive} session${m.alive === 1 ? '' : 's'} running${m.outside ? `, ${m.outside} not on the dashboard` : ''}`,
@@ -588,14 +1078,23 @@ function renderMachine(m, cards, idleHours = 0) {
588
1078
  if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
589
1079
  // What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
590
1080
  if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
1081
+ // And k0 itself, which this chip measured everybody but. The three numbers after the weight are
1082
+ // the ones that were wrong for a long time without anybody being able to see them.
1083
+ if (k0?.rss) {
1084
+ const cpu = k0.cpu === null || k0.cpu === undefined ? '—' : `${Math.round(k0.cpu * 100)}%`
1085
+ lines.push(
1086
+ `k0 itself: ${weight(k0.rss)} · CPU ${cpu} · git ${k0.git}/min · ` +
1087
+ `${k0.sql.kept} queries kept · looking every ${Math.round(k0.pace / 1000)}s`
1088
+ )
1089
+ }
591
1090
  // The chip has room for three words; the sentence that explains them goes here, and it is also
592
1091
  // the only place that can say the closing is switched off — the chip says that by staying quiet.
593
1092
  lines.push(
594
1093
  idleHours > 0
595
- ? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the card stays, Resume picks it up`
1094
+ ? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the story stays, Resume picks it up`
596
1095
  : 'closing forgotten terminals is switched off'
597
1096
  )
598
- lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
1097
+ lines.push(showLoad ? 'click to hide the weight on each story' : 'click to show the weight on each story')
599
1098
  el.title = lines.join('\n')
600
1099
  }
601
1100
 
@@ -615,34 +1114,154 @@ function toast(msg, ms = 4000) {
615
1114
  toast.timer = setTimeout(() => t.classList.remove('show'), ms)
616
1115
  }
617
1116
 
1117
+ /**
1118
+ * What a launched terminal is told to say, whichever door it came through. There are three of them
1119
+ * now — the story's own prompt, a command the interface suggested, and an epic that has no story to
1120
+ * belong to — and one sentence about how it went, so they cannot report the same thing three ways.
1121
+ */
1122
+ function launched(r) {
1123
+ // Switched off, every door into the backlog answers with the same sentence and an HTTP 200 — a
1124
+ // skill has to be able to tell "there is nothing here" from "you turned this off", and neither
1125
+ // of those is an error. `api()` only throws on an error status, so a refusal arrives here looking
1126
+ // like a launch with no window in it, and without this the user would be told the opposite of
1127
+ // what happened about a session that was never opened.
1128
+ if (r.enabled === false) return toast(r.why || 'The k0 backlog is switched off.', 8000)
1129
+ if (!r.up) return toast(`${r.name} was launched, but I didn't see it come up within 30s`)
1130
+ // `autoSent` before `pasted`, and they are not exclusive: a prompt sent with the session goes on
1131
+ // the command line, which counts as pasted too. Asking somebody to hit enter on a session that
1132
+ // is already answering is telling them the opposite of what happened.
1133
+ if (r.autoSent && r.pasted) return toast(`${r.name} is running and the prompt has gone in`)
1134
+ if (r.autoSent) {
1135
+ const why = `without the Accessibility permission I can't leave the prompt waiting`
1136
+ return toast(`${r.name} started on its own (${why})`, 8000)
1137
+ }
1138
+ if (r.pasted) return toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
1139
+ toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
1140
+ }
1141
+
618
1142
  async function start(id, mode) {
619
1143
  toast(mode === 'resume' ? 'Resuming the session…' : 'Opening the terminal…', 30000)
620
1144
  try {
621
- const r = await api(`/api/card/${id}/${mode}`, { method: 'POST' })
622
- if (!r.up) toast(`${r.name} was launched, but I didn't see it come up within 30s`)
623
- else if (r.pasted) toast(`${r.name} is ready, the prompt is in the terminal: hit enter`)
624
- else if (r.autoSent) toast(`${r.name} started on its own (without the Accessibility permission I can't leave the prompt waiting)`, 8000)
625
- else toast(`${r.name} is running, but I couldn't write the prompt`, 8000)
1145
+ launched(await api(`/api/story/${id}/${mode}`, { method: 'POST' }))
626
1146
  } catch (e) {
627
1147
  toast(`Couldn't do it: ${e.message}`, 8000)
628
1148
  }
629
1149
  refresh()
630
1150
  }
631
1151
 
1152
+ /**
1153
+ * The suggestion, pressed: a session that opens saying `/k0-plan K42` instead of what the note
1154
+ * says. The command is the server's own word — it came down on the story and goes back up
1155
+ * unchanged — and the server holds it against a closed list before it goes anywhere near a command
1156
+ * line, so a refusal here is a sentence and not a silence.
1157
+ */
1158
+ async function startCommand(story, command) {
1159
+ toast(`Opening the terminal on /${command} ${story.key ?? ''}`.trim() + '…', 30000)
1160
+ try {
1161
+ launched(await api(`/api/backlog/story/${story.id}/start`, {
1162
+ method: 'POST',
1163
+ body: JSON.stringify({ command }),
1164
+ }))
1165
+ } catch (e) {
1166
+ toast(`Couldn't do it: ${e.message}`, 8000)
1167
+ }
1168
+ lastSignature = ''
1169
+ refresh()
1170
+ }
1171
+
1172
+ /**
1173
+ * An epic, which is not a row anywhere yet and is not meant to be: a session in that repository
1174
+ * running `/k0-epic`, and the discussion writes the epic down itself once it knows what it is.
1175
+ * That is why this takes a repository and nothing else — there is nothing to type in.
1176
+ */
1177
+ async function startEpic(path) {
1178
+ toast('Opening the terminal…', 30000)
1179
+ try {
1180
+ launched(await api('/api/backlog/epic/start', { method: 'POST', body: JSON.stringify({ project_path: path }) }))
1181
+ } catch (e) {
1182
+ toast(`Couldn't do it: ${e.message}`, 8000)
1183
+ }
1184
+ lastSignature = ''
1185
+ refresh()
1186
+ }
1187
+
1188
+ /**
1189
+ * The other way an epic can open a terminal: one that exists, and a command told to run on the
1190
+ * whole of it — `/k0-ultracode K7`. It is `startCommand` for something that is not a story, which
1191
+ * is why it cannot be that function: the address is the epic's, and there is no session row at the
1192
+ * end of it to come back with.
1193
+ */
1194
+ async function startEpicCommand(epic, command) {
1195
+ toast(`Opening the terminal on /${command} ${epic.key ?? ''}`.trim() + '…', 30000)
1196
+ try {
1197
+ launched(await api(`/api/backlog/epic/${epic.id}/start`, { method: 'POST', body: JSON.stringify({ command }) }))
1198
+ } catch (e) {
1199
+ toast(`Couldn't do it: ${e.message}`, 8000)
1200
+ }
1201
+ lastSignature = ''
1202
+ refresh()
1203
+ }
1204
+
632
1205
  async function focusTerminal(id) {
633
- const r = await api(`/api/card/${id}/focus`, { method: 'POST' })
634
- if (!r.ok) toast(r.error)
1206
+ // The answer says `ok: false` when the window could not be brought up, and the request itself
1207
+ // can fail — a story deleted in another tab, the server restarting. Both are the same news to
1208
+ // whoever double-clicked, and neither may be a double click that did nothing.
1209
+ //
1210
+ // `resumable` is the difference between news and a dead end: the window is gone but there is a
1211
+ // conversation behind it, and the one thing worth doing about that is offered rather than
1212
+ // described. Answered with a question and not by resuming on its own — a double click asked for
1213
+ // the window that was there, not for a new one.
1214
+ try {
1215
+ const r = await api(`/api/story/${id}/focus`, { method: 'POST' })
1216
+ if (r.ok) return
1217
+ const said = r.error || 'That terminal could not be brought up front'
1218
+ if (!r.resumable) return toast(said)
1219
+ if (await ask(`${said} Open it again?`, { yes: 'Resume', destructive: false })) start(id, 'resume')
1220
+ } catch (e) {
1221
+ toast(`Couldn't do it: ${e.message}`)
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Starting a story, with the one thing the backlog has to say about it: this waits on something
1227
+ * that is not finished.
1228
+ *
1229
+ * It asks and then it goes ahead — it never refuses. A dependency is a note you left yourself, not
1230
+ * a lock, and a backlog that will not let you work on what you want is a backlog you stop using.
1231
+ * Asked once, per click: there is nothing remembered here, because the answer belongs to this
1232
+ * moment and the dependency may well be closed by the next one.
1233
+ */
1234
+ async function mayStart(story) {
1235
+ const open = waitingOn(story)
1236
+ if (!open.length) return true
1237
+ const list = open.map((d) => `${d.key} ${d.title}`).join(', ')
1238
+ return await ask(`“${story.title}” waits on ${list}, which ${open.length === 1 ? 'is' : 'are'} not done.`, {
1239
+ yes: 'Start anyway',
1240
+ destructive: false,
1241
+ })
1242
+ }
1243
+
1244
+ async function startStory(story) {
1245
+ if (!(await mayStart(story))) return
1246
+ start(story.id, 'start')
635
1247
  }
636
1248
 
637
1249
  /**
638
- * The house confirmation, not the browser's: it is only ever asked before throwing a card away,
639
- * which is the one thing there is no coming back from. "Done" is not — that one is undone with
640
- * Reopen.
1250
+ * The house confirmation, not the browser's. It is asked before throwing a story away — the one
1251
+ * thing there is no coming back from — and before starting work that waits on something else.
1252
+ * "Done" is not asked about: that one is undone with Reopen.
1253
+ *
1254
+ * The two are not the same question and they must not look the same. Throwing away is red and
1255
+ * says `Delete`; going ahead anyway is the ordinary dark button, because it is the answer you are
1256
+ * expected to give.
641
1257
  */
642
- function ask(text) {
1258
+ function ask(text, { yes = 'Delete', destructive = true } = {}) {
643
1259
  const dlg = $('#confirm')
644
1260
  return new Promise((resolve) => {
645
1261
  $('#confirm-text').textContent = text
1262
+ const go = $('#confirm-yes')
1263
+ go.textContent = yes
1264
+ go.className = destructive ? 'destructive' : 'primary'
646
1265
  const close = (answer) => {
647
1266
  dlg.close()
648
1267
  resolve(answer)
@@ -654,20 +1273,26 @@ function ask(text) {
654
1273
  })
655
1274
  }
656
1275
 
657
- async function remove(card) {
658
- if (!(await ask(`Delete “${card.title}”?`))) return
659
- await api(`/api/card/${card.id}`, { method: 'DELETE' })
1276
+ async function remove(story) {
1277
+ if (!(await ask(`Delete “${story.title}”?`))) return
1278
+ // A delete that cannot go through must say so. Without this the confirmation closed, the note
1279
+ // stayed exactly where it was, and nothing on the screen had anything to say about it.
1280
+ try {
1281
+ await api(`/api/story/${story.id}`, { method: 'DELETE' })
1282
+ } catch (e) {
1283
+ toast(`Couldn't delete it: ${e.message}`, 8000)
1284
+ }
660
1285
  refresh()
661
1286
  }
662
1287
 
663
1288
  /**
664
- * The terminal goes, the card stays. Stopping the session takes a couple of seconds — it is asked
1289
+ * The terminal goes, the story stays. Stopping the session takes a couple of seconds — it is asked
665
1290
  * to leave and given the time to — so it says what it is doing while it waits.
666
1291
  */
667
1292
  async function closeSession(id) {
668
1293
  toast('Closing the terminal…', 10000)
669
1294
  try {
670
- await api(`/api/card/${id}/close`, { method: 'POST' })
1295
+ await api(`/api/story/${id}/close`, { method: 'POST' })
671
1296
  toast('Closed. Resume picks the conversation up where it was')
672
1297
  } catch (e) {
673
1298
  toast(`Couldn't do it: ${e.message}`, 8000)
@@ -675,33 +1300,98 @@ async function closeSession(id) {
675
1300
  refresh()
676
1301
  }
677
1302
 
1303
+ /**
1304
+ * Done, and the one answer it can get that is not yes.
1305
+ *
1306
+ * The server refuses a story whose last counter-check found a decision broken, and it refuses it
1307
+ * with the reason written out. Without the catch that refusal was thrown into nothing: no toast,
1308
+ * no redraw, a post-it that sat exactly as it was — the most used button in k0 doing nothing at
1309
+ * all, on the one no the whole feature exists to say. It is shown for longer than the others
1310
+ * because it is a paragraph and not an acknowledgement.
1311
+ */
678
1312
  async function setCompleted(id, completed) {
679
- await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
1313
+ try {
1314
+ await api(`/api/story/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
1315
+ } catch (e) {
1316
+ toast(refusal(e), 12000)
1317
+ }
680
1318
  refresh()
681
1319
  }
682
1320
 
683
- // ── Editor ─────────────────────────────────────────────────────────
1321
+ // ── Making something ───────────────────────────────────────────────
1322
+ /**
1323
+ * The little menu under the `+`.
1324
+ *
1325
+ * It is a menu and not a second dialog because the choice is one word long — and because the two
1326
+ * words are the whole difference between them: a story is typed here, an epic is told. It only
1327
+ * exists where there is a backlog; without one the `+` goes straight to the story dialog it always
1328
+ * went to.
1329
+ */
1330
+ function toggleNewMenu(open = null) {
1331
+ const menu = $('#new-menu')
1332
+ const show = open === null ? menu.hidden : open
1333
+ menu.hidden = !show
1334
+ $('#new-story').setAttribute('aria-expanded', show ? 'true' : 'false')
1335
+ }
1336
+
1337
+ /**
1338
+ * The epic dialog, which asks for a repository and nothing else.
1339
+ *
1340
+ * There is no title field and there is not going to be one. An epic is what is left when a
1341
+ * discussion has finished arguing about what the work is; typing a name for it first is naming
1342
+ * something nobody has decided the shape of yet, and that name is then what every story under it
1343
+ * inherits. So this opens a terminal and `/k0-epic` does the asking.
1344
+ */
1345
+ function openEpicDialog() {
1346
+ const sel = $('#e-project')
1347
+ // In the order they were last used, which for this one dialog is the right order: you are about
1348
+ // to spend an hour talking about a repository, and it is almost always the one you were just in.
1349
+ sel.innerHTML = projects
1350
+ .map((p) => `<option value="${esc(p.path)}">${esc(p.name)}</option>`)
1351
+ .join('')
1352
+ const here = laneEpic?.path || repoFilter || projects[0]?.path
1353
+ if (here) sel.value = here
1354
+ $('#epic-new').showModal()
1355
+ }
1356
+
684
1357
  /**
685
1358
  * `presetPath` is the repository of the column you pressed "+" on: it arrives already chosen and
686
1359
  * the cursor jumps straight to the title, which is the only thing missing.
687
1360
  */
688
- function openEditor(card, presetPath = null) {
689
- editing = card?.id ?? null
690
- editingCard = card ?? null
1361
+ function openEditor(story, presetPath = null) {
1362
+ editing = story?.id ?? null
1363
+ editingStory = story ?? null
691
1364
  // A repository typed by hand does not count: only a real path counts, taken from the list or
692
1365
  // handed over by the column.
693
- chosenProject = card?.project_path ?? presetPath ?? null
694
- $('#editor-title').textContent = card ? 'Card' : 'New card'
1366
+ chosenProject = story?.project_path ?? presetPath ?? null
1367
+ $('#editor-title').textContent = story ? 'Story' : 'New story'
695
1368
  $('#f-project').value = chosenProject ? nameOf(chosenProject) : ''
696
1369
  // Session already started: the repository cannot be moved out from under Claude's feet.
697
- $('#f-project').disabled = !!card?.session_id
1370
+ $('#f-project').disabled = !!story?.session_id
698
1371
  $('#f-project').classList.remove('bad')
699
- $('#f-title').value = card?.title ?? ''
700
- $('#f-prompt').value = card?.prompt ?? ''
701
- $('#f-delete').style.display = card ? '' : 'none'
1372
+ $('#f-title').value = story?.title ?? ''
1373
+ // Notes and the flag are things you have an opinion about once the story exists and you have
1374
+ // been back to it. Asked at the moment of writing it down they are two fields between you and
1375
+ // the only two things there are to do with a new story.
1376
+ $('#f-notes-row').hidden = !story
1377
+ $('#f-notes').value = story?.description ?? ''
1378
+ // The one the backlog adds. It is not on the dialog at all with the feature off: an empty field
1379
+ // for something that does not exist is a question nobody can answer. The flag is not here at
1380
+ // all — it is put on and taken off from the note, which is where you are when you decide.
1381
+ $('#f-epic-row').hidden = !backlogOn
1382
+ $('#f-epic').value = story?.epic_title ?? ''
1383
+ fillEpics()
1384
+ $('#f-delete').style.display = story ? '' : 'none'
1385
+ // Saving without starting anything is what editing a story is for. A NEW story on a board with
1386
+ // a backlog does not have it: there are two roads out of this dialog and both of them open a
1387
+ // terminal — the long way round through a discussion, or straight in. A board with the backlog
1388
+ // switched off keeps it, because that board is the board it always was.
1389
+ $('#f-save').style.display = story || !backlogOn ? '' : 'none'
1390
+ // The long way in, and only where there is a backlog to discuss anything into.
1391
+ $('#f-discuss').style.display = !story && backlogOn ? '' : 'none'
702
1392
  // A live session is not restarted: there is only saving to do there.
703
- $('#f-start').style.display = card?.session_alive ? 'none' : ''
704
- $('#f-start').textContent = card?.session_id ? 'Resume' : 'Start'
1393
+ $('#f-start').style.display = story?.session_alive ? 'none' : ''
1394
+ $('#f-start').textContent = story?.session_id ? 'Resume' : 'Quick Start'
705
1395
  $('#f-project-list').hidden = true
706
1396
  $('#editor').showModal()
707
1397
  ;(chosenProject ? $('#f-title') : $('#f-project')).focus()
@@ -710,6 +1400,86 @@ function openEditor(card, presetPath = null) {
710
1400
 
711
1401
  const nameOf = (p) => projects.find((x) => x.path === p)?.name ?? p
712
1402
 
1403
+ /** The epics of the repository the dialog is on. An epic belongs to one repository and only one. */
1404
+ const epicsHere = () => allEpics.filter((e) => e.project_path === chosenProject)
1405
+
1406
+ /**
1407
+ * The names the epic field offers. A datalist and not a `<select>`, because the field has to do two
1408
+ * things at once: pick one of the epics this repository already has, or name one that does not
1409
+ * exist yet — and a dropdown cannot be typed into.
1410
+ *
1411
+ * The key is the option's label rather than its value: what you leave in the field is a name, so
1412
+ * that a name typed from scratch and a name picked from the list are the same thing to `save`.
1413
+ */
1414
+ function fillEpics() {
1415
+ $('#f-epics').innerHTML = epicsHere()
1416
+ .map((e) => `<option value="${esc(e.title)}">${esc(e.key)}</option>`)
1417
+ .join('')
1418
+ }
1419
+
1420
+ /**
1421
+ * Which epic the dialog is asking for, as a key: one that already exists, one made on the spot, or
1422
+ * `null` for none.
1423
+ *
1424
+ * Made on the spot is the whole point of the field. An epic wanted while writing a story is an epic
1425
+ * wanted now, and sending somebody to another screen to make one first is how a story ends up
1426
+ * outside the epic it belongs to for ever.
1427
+ */
1428
+ async function chosenEpic() {
1429
+ const name = $('#f-epic').value.trim()
1430
+ if (!name) return null
1431
+ const q = name.toLowerCase()
1432
+ const found = epicsHere().find((e) => e.title.toLowerCase() === q || e.key.toLowerCase() === q)
1433
+ if (found) return found.key
1434
+ const made = await api('/api/backlog/epic', {
1435
+ method: 'POST',
1436
+ body: JSON.stringify({ project_path: chosenProject, title: name }),
1437
+ })
1438
+ return made.key
1439
+ }
1440
+
1441
+ /**
1442
+ * The two fields that are the backlog's and not the board's, written after the story itself.
1443
+ *
1444
+ * They go through `/api/backlog/story/:id`, which is the door that resolves an epic's key and
1445
+ * writes the `.k0/` file; the board's own door knows about neither. It is a second request and it
1446
+ * is worth it: it means the story is saved whatever happens here, and if the epic cannot be made —
1447
+ * a repository gone read-only, a name that collides — what is said is that the epic did not happen,
1448
+ * not that the story did not.
1449
+ */
1450
+ async function stampBacklog(id, story) {
1451
+ if (!backlogOn) return
1452
+ const before = story?.epic_title ?? ''
1453
+ const typed = $('#f-epic').value.trim()
1454
+ if (typed === before) return
1455
+ try {
1456
+ const change = { epic_key: await chosenEpic() }
1457
+ await api(`/api/backlog/story/${id}`, { method: 'PATCH', body: JSON.stringify(change) })
1458
+ } catch (e) {
1459
+ toast(`The story is saved, but: ${e.message}`, 8000)
1460
+ }
1461
+ }
1462
+
1463
+ /**
1464
+ * On and off, from the note itself.
1465
+ *
1466
+ * Through the backlog's own door and not the board's: it is the one that writes the `.k0/` file,
1467
+ * so a flag put on here is on the disk copy too and survives a lost database like everything else
1468
+ * about the story.
1469
+ */
1470
+ async function setFlag(story) {
1471
+ try {
1472
+ await api(`/api/backlog/story/${story.id}`, {
1473
+ method: 'PATCH',
1474
+ body: JSON.stringify({ starred: !story.starred }),
1475
+ })
1476
+ } catch (e) {
1477
+ toast(`Couldn't do it: ${e.message}`, 8000)
1478
+ }
1479
+ lastSignature = ''
1480
+ refresh()
1481
+ }
1482
+
713
1483
  let hits = [] // the repositories shown in the list right now
714
1484
  let active = 0 // where you are with the arrows: the row Enter picks
715
1485
 
@@ -718,6 +1488,8 @@ function chooseProject(path) {
718
1488
  $('#f-project').value = nameOf(path)
719
1489
  $('#f-project').classList.remove('bad')
720
1490
  $('#f-project-list').hidden = true
1491
+ // An epic belongs to one repository: changing the repository changes which ones are on offer.
1492
+ fillEpics()
721
1493
  }
722
1494
 
723
1495
  /** With a repository already chosen the whole list is shown: that is where you change it. */
@@ -774,7 +1546,25 @@ function resolveProject() {
774
1546
  el.classList.remove('bad')
775
1547
  }
776
1548
 
777
- /** Returns the saved card, or null if it could not be saved. */
1549
+ /**
1550
+ * The notes, as a list.
1551
+ *
1552
+ * A dash is put in front of every line that has not got one, on the way to being saved rather than
1553
+ * as you type: a box that rewrites the line under the cursor is a box that fights you in the middle
1554
+ * of a word. Blank lines go, because a bullet with nothing after it is not a note.
1555
+ *
1556
+ * It is what makes the box a list and not a paragraph — which is the whole difference between
1557
+ * somewhere to jot three things down and somewhere to write an essay nobody will read again.
1558
+ */
1559
+ const bulleted = (text) =>
1560
+ String(text ?? '')
1561
+ .split('\n')
1562
+ .map((line) => line.trim())
1563
+ .filter(Boolean)
1564
+ .map((line) => (/^[-*•]\s*/.test(line) ? line.replace(/^[-*•]\s*/, '- ') : `- ${line}`))
1565
+ .join('\n')
1566
+
1567
+ /** Returns the saved story, or null if it could not be saved. */
778
1568
  async function save() {
779
1569
  if (!$('#editor-form').reportValidity()) return null
780
1570
  const title = titleCase($('#f-title').value).replace(/-+$/, '')
@@ -790,20 +1580,19 @@ async function save() {
790
1580
  toast('A title is required')
791
1581
  return null
792
1582
  }
793
- // No description here on purpose: a card written by hand does not need one. The field only
794
- // ever mattered for an imported session, where it is the one line saying what happened in
795
- // there — and that one is written by the import. Leaving the key out of the payload is what
796
- // keeps it: `patchCard` writes only the keys it is given.
797
- const payload = {
798
- title,
799
- project_path: chosenProject,
800
- prompt: $('#f-prompt').value,
801
- }
1583
+ // The notes go in only when the dialog was showing them, which is to say only when a story was
1584
+ // being edited. Leaving the key out of the payload is what keeps what is already there:
1585
+ // `patchStory` writes only the keys it is given, so a new story is written without touching a
1586
+ // description an import may later put on it.
1587
+ const payload = { title, project_path: chosenProject }
1588
+ if (!$('#f-notes-row').hidden) payload.description = bulleted($('#f-notes').value)
802
1589
  try {
803
1590
  const saved = editing
804
- ? await api(`/api/card/${editing}`, { method: 'PATCH', body: JSON.stringify(payload) })
805
- : await api('/api/card', { method: 'POST', body: JSON.stringify(payload) })
1591
+ ? await api(`/api/story/${editing}`, { method: 'PATCH', body: JSON.stringify(payload) })
1592
+ : await api('/api/story', { method: 'POST', body: JSON.stringify(payload) })
1593
+ await stampBacklog(saved.id, editingStory)
806
1594
  $('#editor').close()
1595
+ lastSignature = ''
807
1596
  refresh()
808
1597
  return saved
809
1598
  } catch (e) {
@@ -859,10 +1648,84 @@ function setMode(mode, reason) {
859
1648
  }
860
1649
  }
861
1650
 
1651
+ /**
1652
+ * The dot beside the name, and the sentence it carries.
1653
+ *
1654
+ * Two different pieces of news share one mark: k0 moved on under you while you were not looking,
1655
+ * and npm has something newer than what is installed here. They are not the same thing and the
1656
+ * title says which, because a mark that could mean either is a mark you learn to ignore.
1657
+ *
1658
+ * It is a link and nothing else. Nothing here opens the page, and there is no number on the dot:
1659
+ * there is nothing to count, and a badge would make an upgrade feel like an unread inbox.
1660
+ */
1661
+ function setWhatsNew(u) {
1662
+ const mark = $('#whatsnew')
1663
+ if (!mark) return
1664
+ const jumped = u?.jump ? `k0 went from ${u.jump.from} to ${u.jump.to} — see what changed` : null
1665
+ const newer = u?.latest?.newer ? `npm has k0 ${u.latest.version}; this one is ${u.version}` : null
1666
+ const why = jumped || newer
1667
+ mark.hidden = !why
1668
+ if (why) {
1669
+ mark.title = why
1670
+ mark.setAttribute('aria-label', why)
1671
+ }
1672
+ }
1673
+
1674
+ /**
1675
+ * What the list view is lent, and the whole of what it is lent.
1676
+ *
1677
+ * It is handed over rather than imported from the other side, so the list has no way to reach back
1678
+ * into the board: everything it can do to a story is on this object, and the day one of these
1679
+ * changes there is one place that says so. `redraw` is the pair of lines every write in here ends
1680
+ * with — forget what is on screen, and ask again.
1681
+ */
1682
+ const LIST = {
1683
+ api,
1684
+ toast,
1685
+ hue: epicHue,
1686
+ flag: ICON.flag,
1687
+ next: doNextStep,
1688
+ // The one thing the panel can start that a note cannot: a command told to run on a whole epic.
1689
+ epicCommand: startEpicCommand,
1690
+ redraw: () => {
1691
+ lastSignature = ''
1692
+ refresh()
1693
+ },
1694
+ }
1695
+
862
1696
  // ── The loop ──────────────────────────────────────────────────────
1697
+ /**
1698
+ * The last answer that had a board in it, and the stamp that came with it.
1699
+ *
1700
+ * The stamp goes back to the server on the next round, and if the board would be the same answer
1701
+ * twice the server says so in a couple of hundred bytes instead of sending two thirds of a
1702
+ * megabyte again. On a board nobody is touching that is every round of every second of every day:
1703
+ * it came to fifty-six gigabytes through the browser in twenty-four hours, to draw post-its that
1704
+ * had not moved.
1705
+ */
1706
+ let lastBoard = null
1707
+ let stamp = ''
1708
+
863
1709
  async function refresh() {
864
1710
  try {
865
- const data = await api('/api/board')
1711
+ const answer = await api(`/api/board${stamp ? `?since=${encodeURIComponent(stamp)}` : ''}`)
1712
+ stamp = answer.stamp ?? ''
1713
+ // The first answer is always a whole one — the stamp starts empty — so `held` cannot be null
1714
+ // here unless the server is older than this page, and then it simply never says `same`.
1715
+ const settled = answer.same ? lastBoard : (lastBoard = answer)
1716
+ // A server older than this page: it never says `same`, so this cannot happen twice.
1717
+ if (!settled) return true
1718
+
1719
+ // The weights travel apart from the stories, because they are the one thing on a post-it that
1720
+ // changes every second, and put back on here so everything below reads a story the way it
1721
+ // always has. Only a live session has one.
1722
+ const loads = answer.loads ?? {}
1723
+ const data = {
1724
+ ...settled,
1725
+ ...answer,
1726
+ stories: settled.stories.map((s) => (loads[s.id] ? { ...s, load: loads[s.id] } : s)),
1727
+ }
1728
+
866
1729
  // The icon in the tab is tinted like the one in the menu bar: red if a session is waiting
867
1730
  // for you, blue if they are grinding away, outline if everything is still.
868
1731
  setFavicon(data.urgent)
@@ -871,24 +1734,67 @@ async function refresh() {
871
1734
  // is not covered, the lit button has to say so instead of pretending otherwise.
872
1735
 
873
1736
  setMode(data.mode, data.reason)
1737
+ setWhatsNew(data.update)
1738
+ const wasOn = backlogOn
1739
+ backlogOn = !!data.backlog
1740
+ // Every epic k0 knows, kept for the editor's list: the dialog is opened between rounds and has
1741
+ // to be able to offer them without a request of its own.
1742
+ allEpics = data.epics ?? []
1743
+ // The switch belongs to the bar and not to the board: it has to appear the moment the server
1744
+ // says there is a backlog. Only then, though — it used to be rewritten every second to say
1745
+ // the same thing.
1746
+ if (wasOn !== backlogOn) renderColumnsSwitch()
874
1747
  machine = data.machine ?? null
875
- // What is eating most right now: it is what tints that one red and nobody else.
876
- heaviest =
877
- data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
878
- renderMachine(machine, data.cards, data.closeIdleAfterHours)
1748
+ k0 = data.k0 ?? null
1749
+ // What is eating most right now: it is what tints that one red and nobody else. Read off the
1750
+ // weights rather than by sorting every story on the board, since only a handful have one.
1751
+ heaviest = Number(Object.entries(loads).sort((a, b) => b[1].rss - a[1].rss)[0]?.[0]) || null
1752
+ renderMachine(machine, data.stories, data.closeIdleAfterHours)
879
1753
 
880
1754
  // Redraw only if something really changed, so the editor and the focus stay where they are.
881
1755
  // The git state is inside the signature: without it, the mark would stay the one from the
882
1756
  // first round. The weight goes in already rounded — see `weight` — otherwise the board
883
1757
  // would redraw on every sample of the load.
1758
+ // Nothing the server sent can have moved: it hashed exactly what it would have sent, and the
1759
+ // hash matched. What is left in the signature below is this page's own state — the filters,
1760
+ // the lane, which way the columns are read — and every one of those clears `lastSignature`
1761
+ // itself the moment you change it. The only other one is the heaviest story, and that
1762
+ // counts only while the machine is under pressure. So there is nothing to compare: no second
1763
+ // pass over six hundred stories, and no string a hundred kilobytes long built to prove that
1764
+ // nothing happened.
1765
+ if (answer.same && lastSignature && machine?.pressure === 'ok') return false
1766
+
884
1767
  const sig = JSON.stringify([
885
1768
  showLoad,
886
1769
  // The column order depends on it: changing mode means the board has to be rebuilt.
887
1770
  data.mode,
1771
+ // Switching the backlog off takes a button off every post-it that has never been started,
1772
+ // and a change nothing in here noticed would only show up the next time anything else did.
1773
+ backlogOn,
1774
+ // What a column means, which repository the state columns are showing, and which epic the
1775
+ // board is standing inside: three choices that rebuild the whole board and none of which
1776
+ // the server knows anything about.
1777
+ columnsMean,
1778
+ repoFilter,
1779
+ laneEpic && `${laneEpic.path}:${laneEpic.key}`,
1780
+ // The epics, for the progress on the strip at the top and for the labels on the notes. Only
1781
+ // with the backlog on: off, there are none, and a null here is one fewer thing to compare.
1782
+ backlogOn
1783
+ ? (data.epics ?? []).map((e) => [
1784
+ e.key,
1785
+ e.project_path,
1786
+ e.title,
1787
+ e.progress.done,
1788
+ e.progress.total,
1789
+ // Which round the discussion is on. The list draws it, and it is the only thing moving
1790
+ // on an epic that has not turned into stories yet — which is most of an epic's life.
1791
+ e.round?.n ?? '',
1792
+ ])
1793
+ : null,
888
1794
  // Not `heaviest` as it is: on a calm machine the heaviest changes constantly without
889
1795
  // anything changing on screen, and it would redraw for nothing.
890
1796
  machine?.pressure !== 'ok' ? heaviest : null,
891
- data.cards.map((c) => [
1797
+ data.stories.map((c) => [
892
1798
  c.id,
893
1799
  c.status,
894
1800
  c.session_alive,
@@ -902,6 +1808,19 @@ async function refresh() {
902
1808
  c.updated_at,
903
1809
  gitSig(c.git),
904
1810
  c.load ? weight(c.load.rss) : '',
1811
+ // What the backlog draws on the note and on a row in the list. They are all `undefined`
1812
+ // with the feature off, which compares as well as anything else.
1813
+ c.state,
1814
+ c.starred,
1815
+ c.epic_key,
1816
+ c.epic_title,
1817
+ c.blocked,
1818
+ // The label on the one button that says what to do next. It can change without anything
1819
+ // else on the row changing, and a suggestion nothing noticed would stay wrong until
1820
+ // something else moved. The command as well as the label, because `Ultracode` appears and
1821
+ // disappears with the command behind it and not with the words on the button.
1822
+ c.next_step?.label ?? '',
1823
+ c.next_step?.command ?? '',
905
1824
  ]),
906
1825
  // The server state belongs in here for the same reason the git state does: without it the
907
1826
  // globe would keep the colour it had on the first round for the rest of the visit.
@@ -912,8 +1831,11 @@ async function refresh() {
912
1831
  lastSignature = sig
913
1832
  render(data)
914
1833
  }
1834
+ // Whether this round was worth making, which is what decides the pace of the next one.
1835
+ return !answer.same
915
1836
  } catch {
916
- /* server down: we try again next round */
1837
+ /* server down: we try again next round, and at the working pace — this is not a quiet board */
1838
+ return true
917
1839
  }
918
1840
  }
919
1841
 
@@ -930,6 +1852,7 @@ async function loadProjects() {
930
1852
  async function boot() {
931
1853
  initView() // before anything else: render() assumes the view is already there
932
1854
  renderFilters()
1855
+ renderColumnsSwitch() // hidden until the first answer says there is a backlog
933
1856
  await loadProjects()
934
1857
 
935
1858
  $('#filters').onclick = (e) => {
@@ -943,7 +1866,7 @@ async function boot() {
943
1866
  refresh()
944
1867
  }
945
1868
 
946
- // The gauge is always there; clicking it switches the weights on the cards off and on again,
1869
+ // The gauge is always there; clicking it switches the weights on the stories off and on again,
947
1870
  // and the choice stays as you left it.
948
1871
  $('#gauge').onclick = () => {
949
1872
  showLoad = !showLoad
@@ -976,17 +1899,104 @@ async function boot() {
976
1899
  }
977
1900
  }
978
1901
 
979
- $('#new-card').onclick = () => openEditor(null)
1902
+ // Kanban or List. It is a radiogroup like the modes are, and it behaves like one: clicking the
1903
+ // lit half does nothing, because you leave a view by going to the other one.
1904
+ $('#columns').onclick = (e) => {
1905
+ const b = e.target.closest('button[data-columns]')
1906
+ if (!b || b.dataset.columns === columnsMean) return
1907
+ columnsMean = b.dataset.columns
1908
+ localStorage.setItem('k0-columns', columnsMean)
1909
+ renderColumnsSwitch()
1910
+ lastSignature = ''
1911
+ refresh()
1912
+ }
1913
+
1914
+ // The repository menu: a button that opens a list with a search in it. Everything that closes
1915
+ // it goes through `showRepoList`, so there is no way to leave the arrow saying one thing and the
1916
+ // list doing another.
1917
+ $('#repo-filter-btn').onclick = (e) => {
1918
+ e.stopPropagation()
1919
+ showRepoList($('#repo-filter-list').hidden)
1920
+ }
1921
+ $('#repo-filter-q').oninput = (e) => paintRepoList(e.target.value)
1922
+ $('#repo-filter-q').onkeydown = (e) => {
1923
+ if (e.key === 'Escape') {
1924
+ e.stopPropagation()
1925
+ showRepoList(false)
1926
+ $('#repo-filter-btn').focus()
1927
+ return
1928
+ }
1929
+ // Enter takes the first row that is left, which is the one the search put at the top: typing
1930
+ // three letters and pressing Enter is the whole gesture, without reaching for the pointer.
1931
+ if (e.key !== 'Enter') return
1932
+ e.preventDefault()
1933
+ $('#repo-filter-rows').querySelector('[data-repo]')?.click()
1934
+ }
1935
+ $('#repo-filter-rows').onclick = (e) => {
1936
+ const row = e.target.closest('[data-repo]')
1937
+ if (!row) return
1938
+ chooseRepo(row.dataset.repo)
1939
+ showRepoList(false)
1940
+ lastSignature = ''
1941
+ refresh()
1942
+ }
1943
+ // Anywhere else on the page shuts it. A menu you have to press the button again to be rid of is
1944
+ // a menu that stays open over the board.
1945
+ document.addEventListener('click', (e) => {
1946
+ if (!$('#repo-filter-list').hidden && !e.target.closest('#repo-filter')) showRepoList(false)
1947
+ })
1948
+
1949
+ // The way out of an epic. It is a button with words on it and not an ✕: going back to the whole
1950
+ // board is not closing something, and there is nothing here to close.
1951
+ $('#epic-out').onclick = () => setLane(null)
1952
+
1953
+ // The `+` has two things to make now, and they are made in two different ways: a story is typed
1954
+ // here, an epic is told to Claude in a terminal. With the backlog off there is only one of them
1955
+ // and the menu never opens — the plus is the plus it has always been.
1956
+ $('#new-story').onclick = () => (backlogOn ? toggleNewMenu() : openEditor(null))
1957
+ $('#new-menu').onclick = (e) => {
1958
+ const b = e.target.closest('button[data-new]')
1959
+ if (!b) return
1960
+ toggleNewMenu(false)
1961
+ if (b.dataset.new === 'story') openEditor(null)
1962
+ else openEpicDialog()
1963
+ }
1964
+ // Anywhere else shuts it, which is what a menu does. It is caught on the way down so a click on
1965
+ // something else still does that something else.
1966
+ document.addEventListener('pointerdown', (e) => {
1967
+ if (!e.target.closest('.new-wrap')) toggleNewMenu(false)
1968
+ })
1969
+ document.addEventListener('keydown', (e) => {
1970
+ if (e.key === 'Escape') toggleNewMenu(false)
1971
+ })
1972
+
1973
+ $('#e-close').onclick = () => $('#epic-new').close()
1974
+ $('#e-cancel').onclick = () => $('#epic-new').close()
1975
+ $('#epic-form').onsubmit = (e) => {
1976
+ e.preventDefault()
1977
+ const path = $('#e-project').value
1978
+ if (!path) return
1979
+ $('#epic-new').close()
1980
+ startEpic(path)
1981
+ }
980
1982
  $('#f-close').onclick = () => $('#editor').close()
981
1983
  $('#editor-form').onsubmit = (e) => {
982
1984
  e.preventDefault()
983
1985
  save()
984
1986
  }
985
- // Save and go: the same thing the button on the card does, without going through it.
1987
+ // Save and go: the same thing the button on the story does, without going through it. Nothing
1988
+ // is sent with it — the terminal opens empty and you type there, which is the whole of what
1989
+ // makes this the quick way in.
986
1990
  $('#f-start').onclick = async () => {
987
1991
  const saved = await save()
988
1992
  if (!saved) return
989
- start(saved.id, editingCard?.session_id ? 'resume' : 'start')
1993
+ start(saved.id, editingStory?.session_id ? 'resume' : 'start')
1994
+ }
1995
+ // And the long way round: the story is written down and the discussion starts on it at once,
1996
+ // which is the same `/k0-discuss K42` the post-it's own button would have opened later.
1997
+ $('#f-discuss').onclick = async () => {
1998
+ const saved = await save()
1999
+ if (saved) startCommand(saved, 'k0-discuss')
990
2000
  }
991
2001
  // Esc with the list open closes only the list: the dialog leaves on the second Esc.
992
2002
  $('#editor').oncancel = (e) => {
@@ -1048,16 +2058,36 @@ async function boot() {
1048
2058
 
1049
2059
  $('#f-delete').onclick = async () => {
1050
2060
  if (!editing || !(await ask(`Delete “${$('#f-title').value}”?`))) return
1051
- await api(`/api/card/${editing}`, { method: 'DELETE' })
2061
+ // The editor stays open when the delete does not go through: closing it would leave the story
2062
+ // on the board with nothing said about why it is still there.
2063
+ try {
2064
+ await api(`/api/story/${editing}`, { method: 'DELETE' })
2065
+ } catch (e) {
2066
+ toast(`Couldn't delete it: ${e.message}`, 8000)
2067
+ return
2068
+ }
1052
2069
  $('#editor').close()
1053
2070
  refresh()
1054
2071
  }
1055
2072
 
1056
2073
  refresh()
1057
- setInterval(refresh, 1000)
2074
+ // Only while the tab is in front of you, and only as often as it is worth: see `whileVisible`.
2075
+ // A still board drops to one round every four seconds, and the first thing that moves — or your
2076
+ // coming back to the tab — puts it straight back to one.
2077
+ whileVisible(refresh, 1000, 4000)
1058
2078
 
1059
- // A direct link: k0 opened with the new card already in front of you.
2079
+ // A direct link: k0 opened with the new story already in front of you.
1060
2080
  if (location.hash === '#new' || location.hash === '#nuovo') openEditor(null)
2081
+ // And the other direct link, the one the Usage page makes: a session's story, already open.
2082
+ const wanted = /^#story-(\d+)$/.exec(location.hash)
2083
+ if (wanted) {
2084
+ api('/api/board')
2085
+ .then((d) => {
2086
+ const story = d.stories.find((s) => s.id === Number(wanted[1]))
2087
+ if (story) openEditor(story)
2088
+ })
2089
+ .catch(() => {})
2090
+ }
1061
2091
  }
1062
2092
 
1063
2093
  boot()