k0-board 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +163 -1
- package/README.md +223 -20
- package/bin/k0-board.js +37 -3
- package/menubar/K0MenuBar.swift +105 -22
- package/menubar/build.sh +7 -1
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/linux/tray.py +42 -2
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/platform/win32/tray.ps1 +11 -0
- package/server/changelog.js +242 -0
- package/server/db.js +58 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/index.js +122 -35
- package/server/machine.js +9 -0
- package/server/servers.js +481 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +162 -12
- package/web/board.js +278 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +9 -7
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
package/web/board.js
CHANGED
|
@@ -2,6 +2,7 @@ import { titleCase } from '/title.js'
|
|
|
2
2
|
import { initView, refit } from '/view.js'
|
|
3
3
|
import { weight, gb } from '/units.js'
|
|
4
4
|
import { setFavicon } from '/favicon.js'
|
|
5
|
+
import { split } from '/recency.js'
|
|
5
6
|
|
|
6
7
|
// ── Statuses ───────────────────────────────────────────────────────
|
|
7
8
|
// The order is the attention priority inside a column: first whoever is waiting for you,
|
|
@@ -19,6 +20,10 @@ const LABEL = {
|
|
|
19
20
|
COMPLETED: 'Done',
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// The statuses where something is really running. The server holds the same rule — a browser
|
|
24
|
+
// cannot import a module that reads the filesystem — the way `ORDER` is already written twice.
|
|
25
|
+
const BUSY = new Set(['WORKING', 'PLANNING'])
|
|
26
|
+
|
|
22
27
|
const $ = (s) => document.querySelector(s)
|
|
23
28
|
const api = async (url, opts) => {
|
|
24
29
|
const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
|
|
@@ -37,6 +42,16 @@ let lastSignature = ''
|
|
|
37
42
|
const hidden = new Set(JSON.parse(localStorage.getItem('k0-hidden') || '["COMPLETED"]'))
|
|
38
43
|
const saveHidden = () => localStorage.setItem('k0-hidden', JSON.stringify([...hidden]))
|
|
39
44
|
|
|
45
|
+
// The repositories you have put away by hand. Unlike `held` this outlives the page: putting a
|
|
46
|
+
// column away is a decision, not a glance.
|
|
47
|
+
const folded = new Set(JSON.parse(localStorage.getItem('k0-folded') || '[]'))
|
|
48
|
+
const saveFolded = () => localStorage.setItem('k0-folded', JSON.stringify([...folded]))
|
|
49
|
+
|
|
50
|
+
// The columns open in this visit. Within one visit the set only ever grows: a column does not
|
|
51
|
+
// fold out from under you while you are working — it folds the next time the page loads. It is
|
|
52
|
+
// deliberately not remembered, so that reloading is what tidies the board up.
|
|
53
|
+
const held = new Set()
|
|
54
|
+
|
|
40
55
|
// The weight of each session: on to begin with, switched off from the gauge at the top.
|
|
41
56
|
let showLoad = localStorage.getItem('k0-load') !== '0'
|
|
42
57
|
let machine = null // how the computer is doing right now
|
|
@@ -84,8 +99,17 @@ function postit(card, now) {
|
|
|
84
99
|
card.id
|
|
85
100
|
)}${loadChip(card)}</div>
|
|
86
101
|
<div class="title">${esc(card.title)}</div>
|
|
87
|
-
${
|
|
88
|
-
|
|
102
|
+
${
|
|
103
|
+
// 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
|
|
105
|
+
// the buttons stay where they are.
|
|
106
|
+
card.description || card.prompt
|
|
107
|
+
? `<div class="body">
|
|
108
|
+
${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
|
|
109
|
+
${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
|
|
110
|
+
</div>`
|
|
111
|
+
: ''
|
|
112
|
+
}
|
|
89
113
|
<div class="foot">
|
|
90
114
|
${dead ? '<span class="dead">session closed</span>' : ''}
|
|
91
115
|
<span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
|
|
@@ -99,10 +123,11 @@ function postit(card, now) {
|
|
|
99
123
|
}
|
|
100
124
|
|
|
101
125
|
const actions = el.querySelector('.actions')
|
|
102
|
-
const btn = (text, fn, title) => {
|
|
126
|
+
const btn = (text, fn, title, cls) => {
|
|
103
127
|
const b = document.createElement('button')
|
|
104
128
|
b.textContent = text
|
|
105
129
|
if (title) b.title = title
|
|
130
|
+
if (cls) b.className = cls
|
|
106
131
|
b.onclick = (e) => {
|
|
107
132
|
e.stopPropagation()
|
|
108
133
|
fn()
|
|
@@ -118,6 +143,16 @@ function postit(card, now) {
|
|
|
118
143
|
if (dead) btn('Resume', () => start(card.id, 'resume'))
|
|
119
144
|
// An idea in the backlog cannot be "done": it never started.
|
|
120
145
|
btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
|
|
146
|
+
// Close gives the memory back without declaring the work over, and it comes after Done, as a
|
|
147
|
+
// link rather than a button: it is the rarer of the two and should not compete with it. Not
|
|
148
|
+
// while the session is working — stopping one mid-thought is not memory saved, it is work lost.
|
|
149
|
+
if (!dead && !BUSY.has(card.status))
|
|
150
|
+
btn(
|
|
151
|
+
'Close',
|
|
152
|
+
() => closeSession(card.id),
|
|
153
|
+
'stop the session and its terminal, and leave the card where it is',
|
|
154
|
+
'link'
|
|
155
|
+
)
|
|
121
156
|
}
|
|
122
157
|
|
|
123
158
|
// The git mark is a link: a double click on it would open two tabs and bring the terminal to
|
|
@@ -139,6 +174,11 @@ const ICON = {
|
|
|
139
174
|
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>',
|
|
140
175
|
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>',
|
|
141
176
|
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
177
|
+
// Put this column away: it slides off to the right, where `Old` is.
|
|
178
|
+
fold: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6l6 6-6 6"/><path d="M19 5v14"/></svg>',
|
|
179
|
+
// The dev server: a world, because what is behind it is a site. A meridian and a parallel are
|
|
180
|
+
// enough to read it at twelve pixels — more lines and it goes back to being a circle.
|
|
181
|
+
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>',
|
|
142
182
|
}
|
|
143
183
|
|
|
144
184
|
const esc = (s) =>
|
|
@@ -191,6 +231,95 @@ function gitChip(g, repo, card) {
|
|
|
191
231
|
}${n}</a>`
|
|
192
232
|
}
|
|
193
233
|
|
|
234
|
+
// ── The dev server ────────────────────────────────────────────────────
|
|
235
|
+
// The globe next to a repository's name: grey when its server is off, green when it is up,
|
|
236
|
+
// spinning while it is coming up, red when it tried and could not. One click switches it, two
|
|
237
|
+
// restart it, and when it is green the repository's name becomes the way into the site.
|
|
238
|
+
//
|
|
239
|
+
// The four states are the ones the server sends; nothing is guessed here. The only thing the
|
|
240
|
+
// page knows that the server does not is that a click has just happened and no answer has come
|
|
241
|
+
// back yet — which is what `switching` is for.
|
|
242
|
+
const switching = new Set()
|
|
243
|
+
|
|
244
|
+
/** Everything the globe knows, for the tooltip. Same idea as `gitDetail`: the detail is on hover. */
|
|
245
|
+
function serverDetail(s, state) {
|
|
246
|
+
const bits = []
|
|
247
|
+
if (state === 'up') bits.push(s.adopted ? `up on port ${s.port}, started outside k0` : `up on port ${s.port}`)
|
|
248
|
+
else if (state === 'starting') bits.push('starting…')
|
|
249
|
+
else if (state === 'failed') bits.push(s.error ? `it did not come up: ${s.error}` : 'it did not come up')
|
|
250
|
+
else bits.push('the server is off')
|
|
251
|
+
bits.push(s.command)
|
|
252
|
+
if (state === 'up') bits.push('click to stop, double-click to restart')
|
|
253
|
+
else if (state !== 'starting') bits.push('click to start')
|
|
254
|
+
// Where it went wrong is worth more than any sentence about it going wrong.
|
|
255
|
+
if (state === 'failed') bits.push(s.log)
|
|
256
|
+
return bits.join(' · ')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The globe, or nothing at all. A repository with no way to start a server does not get a
|
|
261
|
+
* button that would quietly do nothing — the server sends null and this draws none.
|
|
262
|
+
*/
|
|
263
|
+
function serverChip(s, repo) {
|
|
264
|
+
if (!s) return ''
|
|
265
|
+
const state = switching.has(repo) ? 'starting' : s.state
|
|
266
|
+
const title = serverDetail(s, state)
|
|
267
|
+
const off = s.can.run ? '' : ' disabled'
|
|
268
|
+
return `<button class="globe ${state}" title="${esc(title)}" aria-label="${esc(title)}"${off}>${ICON.globe}</button>`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One click switches, two restart, and they are fighting over the same first click.
|
|
273
|
+
*
|
|
274
|
+
* What settles it without being seen: when the server is OFF there is nothing to wait for,
|
|
275
|
+
* because "start" and "restart" are the same thing there, so the click goes straight through.
|
|
276
|
+
* Only STOPPING waits the quarter second it takes to learn whether a second click is coming —
|
|
277
|
+
* and even that is invisible, because the globe starts spinning the moment it is pressed.
|
|
278
|
+
*/
|
|
279
|
+
function wireGlobe(el, repo, s) {
|
|
280
|
+
if (!s.can.run) return
|
|
281
|
+
if (s.state === 'starting' || switching.has(repo)) {
|
|
282
|
+
el.disabled = true
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
if (s.state !== 'up') {
|
|
286
|
+
el.onclick = () => switchServer(repo, 'start')
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
let timer = null
|
|
290
|
+
el.onclick = () => {
|
|
291
|
+
if (timer) return // the second click of a double: let `ondblclick` have it
|
|
292
|
+
timer = setTimeout(() => {
|
|
293
|
+
timer = null
|
|
294
|
+
switchServer(repo, 'stop')
|
|
295
|
+
}, 250)
|
|
296
|
+
}
|
|
297
|
+
el.ondblclick = () => {
|
|
298
|
+
clearTimeout(timer)
|
|
299
|
+
timer = null
|
|
300
|
+
switchServer(repo, 'restart')
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function switchServer(repo, action) {
|
|
305
|
+
switching.add(repo)
|
|
306
|
+
lastSignature = '' // the globe has to start spinning now, not at the next round
|
|
307
|
+
await refresh()
|
|
308
|
+
try {
|
|
309
|
+
const s = await api('/api/server', { method: 'POST', body: JSON.stringify({ path: repo, action }) })
|
|
310
|
+
if (s.state === 'failed') toast(s.error || 'The server did not come up. Hover the globe for the log.', 8000)
|
|
311
|
+
} catch (err) {
|
|
312
|
+
toast(err.message)
|
|
313
|
+
} finally {
|
|
314
|
+
switching.delete(repo)
|
|
315
|
+
lastSignature = ''
|
|
316
|
+
refresh()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Just enough to compare one round with the next, exactly as `gitSig` does for git. */
|
|
321
|
+
const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}` : '')
|
|
322
|
+
|
|
194
323
|
// ── What a session weighs ────────────────────────────────────────────
|
|
195
324
|
/**
|
|
196
325
|
* How much this session eats, counting everything it has opened underneath itself. The detail —
|
|
@@ -252,24 +381,140 @@ function render(data) {
|
|
|
252
381
|
// still in alphabetical order — the server's, which is not touched here.
|
|
253
382
|
if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
|
|
254
383
|
|
|
255
|
-
|
|
384
|
+
// Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
|
|
385
|
+
// four rules and for why the day is counted back from your last piece of work rather than from
|
|
386
|
+
// the clock.
|
|
387
|
+
const { open, alive } = split({
|
|
388
|
+
paths: columns.map((c) => c.col.path),
|
|
389
|
+
cards: data.cards,
|
|
390
|
+
folded,
|
|
391
|
+
held,
|
|
392
|
+
})
|
|
393
|
+
// A column open now is open for the rest of the visit, and a column that is open is not one
|
|
394
|
+
// you have put away: a session woke up in there, or you went and fetched it back yourself.
|
|
395
|
+
let forget = false
|
|
396
|
+
for (const p of open) {
|
|
397
|
+
held.add(p)
|
|
398
|
+
if (folded.delete(p)) forget = true
|
|
399
|
+
}
|
|
400
|
+
if (forget) saveFolded()
|
|
401
|
+
|
|
402
|
+
const openSet = new Set(open)
|
|
403
|
+
for (const { col, cards } of columns.filter(({ col }) => openSet.has(col.path))) {
|
|
256
404
|
const wrap = document.createElement('section')
|
|
257
405
|
wrap.className = 'column'
|
|
258
406
|
// The "+" next to the name: a new card on THIS repository, without picking it.
|
|
259
407
|
const add = `New card in ${esc(col.name)}`
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
408
|
+
// And next to it, the one that puts the column away without waiting for it to go quiet on
|
|
409
|
+
// its own. It sits there in plain sight next to the `+`, because a button you have to go
|
|
410
|
+
// hunting for with the pointer is a button nobody knows exists.
|
|
411
|
+
//
|
|
412
|
+
// Where the column cannot be put away it goes faint and refuses, rather than not being drawn
|
|
413
|
+
// at all. Not being drawn was the first idea and it was the wrong one: a column runs and
|
|
414
|
+
// goes back to your turn every few seconds, so the button would have spent the whole session
|
|
415
|
+
// blinking in and out — and it would have done it on exactly the columns you are working on.
|
|
416
|
+
// A control that stays where it was and says why it will not move is worth more than one
|
|
417
|
+
// that is only there when it agrees with you. The title carries the reason.
|
|
418
|
+
const live = alive.has(col.path)
|
|
419
|
+
const away = live ? `${col.name} has something going on: it cannot be put away` : `Put ${col.name} away`
|
|
420
|
+
const fold = `<button class="fold" title="${esc(away)}" aria-label="${esc(away)}"${
|
|
421
|
+
live ? ' disabled' : ''
|
|
422
|
+
}>${ICON.fold}</button>`
|
|
423
|
+
// Two groups and a name between them: what you press on the left, what tells you how things
|
|
424
|
+
// stand on the right. The count of post-its that used to sit after the name is gone — it
|
|
425
|
+
// took up the room the name now uses, and it never answered a question anybody had.
|
|
426
|
+
//
|
|
427
|
+
// With the server up, the name is the door to it. It is the right thing to click for the
|
|
428
|
+
// same reason the git mark is the right thing to click to reach the files: the obvious
|
|
429
|
+
// gesture on the obvious word, leaving the globe free to mean only on and off.
|
|
430
|
+
const url = col.server?.state === 'up' ? col.server.url : null
|
|
431
|
+
const name = url
|
|
432
|
+
? `<a class="name" href="${esc(url)}" target="_blank" title="${esc(url)}">${esc(col.name)}</a>`
|
|
433
|
+
: `<span class="name">${esc(col.name)}</span>`
|
|
434
|
+
wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${
|
|
435
|
+
ICON.plus
|
|
436
|
+
}</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
|
|
263
437
|
wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
|
|
438
|
+
wrap.querySelector('.fold').onclick = () => putAway(col.path)
|
|
439
|
+
const globe = wrap.querySelector('.globe')
|
|
440
|
+
if (globe) wireGlobe(globe, col.path, col.server)
|
|
264
441
|
for (const c of cards) wrap.append(postit(c, data.now))
|
|
265
442
|
board.append(wrap)
|
|
266
443
|
}
|
|
267
444
|
|
|
445
|
+
board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
|
|
268
446
|
board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
|
|
269
447
|
$('#empty').style.display = columns.length ? 'none' : 'grid'
|
|
270
448
|
refit() // the board changed size: the view comes back inside its limits
|
|
271
449
|
}
|
|
272
450
|
|
|
451
|
+
/** Put a column away by hand, and let go of the place it was holding for this visit. */
|
|
452
|
+
function putAway(path) {
|
|
453
|
+
folded.add(path)
|
|
454
|
+
held.delete(path)
|
|
455
|
+
saveFolded()
|
|
456
|
+
lastSignature = ''
|
|
457
|
+
refresh()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Fetch one back: it stays for the rest of the visit, and folds again on the next load. */
|
|
461
|
+
function bringBack(path) {
|
|
462
|
+
held.add(path)
|
|
463
|
+
folded.delete(path)
|
|
464
|
+
saveFolded()
|
|
465
|
+
lastSignature = ''
|
|
466
|
+
refresh()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The second-to-last column: the repositories that DO have work on the board, just not lately —
|
|
471
|
+
* nothing touched in the day before your most recent piece of work, and nothing alive inside.
|
|
472
|
+
* They are the reason the board was three screens wide: a dozen of them, standing at full width
|
|
473
|
+
* between the three you are actually on.
|
|
474
|
+
*
|
|
475
|
+
* They are folded, not thrown away. Click the row and the column comes back at full width for the
|
|
476
|
+
* rest of the visit.
|
|
477
|
+
*
|
|
478
|
+
* It sits next to `Others` and is built the same way, down to the row: a `+`, a name, a git mark
|
|
479
|
+
* and nothing else. How many cards are parked in there and how long it has been quiet were both
|
|
480
|
+
* in here once, and both had to go — three numbers on a row you are meant to read at a glance is
|
|
481
|
+
* two too many, and the answer to either is one click away in the column itself.
|
|
482
|
+
*/
|
|
483
|
+
function oldColumn(columns) {
|
|
484
|
+
if (!columns.length) return document.createDocumentFragment()
|
|
485
|
+
|
|
486
|
+
const wrap = document.createElement('section')
|
|
487
|
+
wrap.className = 'column repos old'
|
|
488
|
+
// Alphabetical, for the same reason `Others` is: these are rows you scan with your eye looking
|
|
489
|
+
// for a name. In driving mode the columns above have queued up by urgency, and letting that
|
|
490
|
+
// order through to here would be a list that reshuffles itself with nothing urgent in it.
|
|
491
|
+
wrap.innerHTML =
|
|
492
|
+
'<h2>Old<small>' +
|
|
493
|
+
columns.length +
|
|
494
|
+
'</small></h2>' +
|
|
495
|
+
[...columns]
|
|
496
|
+
.sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
|
|
497
|
+
.map(({ col }) => {
|
|
498
|
+
const add = `New card in ${esc(col.name)}`
|
|
499
|
+
const back = `Bring ${col.name} back onto the board`
|
|
500
|
+
return `<div class="repo" data-p="${esc(col.path)}">
|
|
501
|
+
<button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
|
|
502
|
+
<button class="name" title="${esc(back)}">${esc(col.name)}</button>${gitChip(col.git, col.path)}
|
|
503
|
+
</div>`
|
|
504
|
+
})
|
|
505
|
+
.join('')
|
|
506
|
+
// The whole row brings the column back, not only the name: at this size, asking for the four
|
|
507
|
+
// words exactly is asking for a second click. The `+` and the git lens are the two things in
|
|
508
|
+
// there that mean something else, and they keep meaning it.
|
|
509
|
+
wrap.onclick = (e) => {
|
|
510
|
+
const path = e.target.closest('.repo')?.dataset.p
|
|
511
|
+
if (!path || e.target.closest('.git')) return
|
|
512
|
+
if (e.target.closest('.add')) openEditor(null, path)
|
|
513
|
+
else bringBack(path)
|
|
514
|
+
}
|
|
515
|
+
return wrap
|
|
516
|
+
}
|
|
517
|
+
|
|
273
518
|
/**
|
|
274
519
|
* The last column: the OTHER repositories, the ones with no column because they have not one
|
|
275
520
|
* card between them. Repeating the ones already on the left would be saying the same thing
|
|
@@ -397,6 +642,21 @@ async function remove(card) {
|
|
|
397
642
|
refresh()
|
|
398
643
|
}
|
|
399
644
|
|
|
645
|
+
/**
|
|
646
|
+
* The terminal goes, the card stays. Stopping the session takes a couple of seconds — it is asked
|
|
647
|
+
* to leave and given the time to — so it says what it is doing while it waits.
|
|
648
|
+
*/
|
|
649
|
+
async function closeSession(id) {
|
|
650
|
+
toast('Closing the terminal…', 10000)
|
|
651
|
+
try {
|
|
652
|
+
await api(`/api/card/${id}/close`, { method: 'POST' })
|
|
653
|
+
toast('Closed. Resume picks the conversation up where it was')
|
|
654
|
+
} catch (e) {
|
|
655
|
+
toast(`Couldn't do it: ${e.message}`, 8000)
|
|
656
|
+
}
|
|
657
|
+
refresh()
|
|
658
|
+
}
|
|
659
|
+
|
|
400
660
|
async function setCompleted(id, completed) {
|
|
401
661
|
await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
|
|
402
662
|
refresh()
|
|
@@ -419,7 +679,6 @@ function openEditor(card, presetPath = null) {
|
|
|
419
679
|
$('#f-project').disabled = !!card?.session_id
|
|
420
680
|
$('#f-project').classList.remove('bad')
|
|
421
681
|
$('#f-title').value = card?.title ?? ''
|
|
422
|
-
$('#f-desc').value = card?.description ?? ''
|
|
423
682
|
$('#f-prompt').value = card?.prompt ?? ''
|
|
424
683
|
$('#f-delete').style.display = card ? '' : 'none'
|
|
425
684
|
// A live session is not restarted: there is only saving to do there.
|
|
@@ -513,9 +772,12 @@ async function save() {
|
|
|
513
772
|
toast('A title is required')
|
|
514
773
|
return null
|
|
515
774
|
}
|
|
775
|
+
// No description here on purpose: a card written by hand does not need one. The field only
|
|
776
|
+
// ever mattered for an imported session, where it is the one line saying what happened in
|
|
777
|
+
// there — and that one is written by the import. Leaving the key out of the payload is what
|
|
778
|
+
// keeps it: `patchCard` writes only the keys it is given.
|
|
516
779
|
const payload = {
|
|
517
780
|
title,
|
|
518
|
-
description: $('#f-desc').value.trim(),
|
|
519
781
|
project_path: chosenProject,
|
|
520
782
|
prompt: $('#f-prompt').value,
|
|
521
783
|
}
|
|
@@ -616,10 +878,16 @@ async function refresh() {
|
|
|
616
878
|
c.description,
|
|
617
879
|
c.prompt,
|
|
618
880
|
c.project_path,
|
|
881
|
+
// What folds a column away and what brings it back: the day is measured from the
|
|
882
|
+
// freshest of these, so when none of them moves nothing can fold, and the board is
|
|
883
|
+
// spared a redraw on a timer.
|
|
884
|
+
c.updated_at,
|
|
619
885
|
gitSig(c.git),
|
|
620
886
|
c.load ? weight(c.load.rss) : '',
|
|
621
887
|
]),
|
|
622
|
-
|
|
888
|
+
// The server state belongs in here for the same reason the git state does: without it the
|
|
889
|
+
// globe would keep the colour it had on the first round for the rest of the visit.
|
|
890
|
+
data.columns.map((col) => [col.path, gitSig(col.git), serverSig(col.server)]),
|
|
623
891
|
(data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
|
|
624
892
|
])
|
|
625
893
|
if (sig !== lastSignature) {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* The ChangeLog: k0's third page. Colours and fonts live in base.css.
|
|
2
|
+
|
|
3
|
+
This one is a sheet of paper, not a board — it is the only page in k0 you read top to bottom
|
|
4
|
+
rather than look at. So it scrolls, it has a measure narrow enough to read comfortably, and
|
|
5
|
+
nothing on it moves. */
|
|
6
|
+
@import url('/base.css');
|
|
7
|
+
|
|
8
|
+
body {
|
|
9
|
+
margin: 0;
|
|
10
|
+
background: var(--bg);
|
|
11
|
+
color: var(--ink);
|
|
12
|
+
font-family: var(--sans);
|
|
13
|
+
font-size: 1rem;
|
|
14
|
+
line-height: 1.55;
|
|
15
|
+
-webkit-font-smoothing: antialiased;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
height: 100%;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* ── The top bar ─────────────────────────────────────────────────────── */
|
|
22
|
+
.topbar {
|
|
23
|
+
display: flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 14px;
|
|
26
|
+
padding: 10px 18px;
|
|
27
|
+
background: var(--paper);
|
|
28
|
+
border-bottom: 1px solid var(--line);
|
|
29
|
+
flex: none;
|
|
30
|
+
}
|
|
31
|
+
.topbar .home {
|
|
32
|
+
font: 700 1.2rem/1 var(--mono);
|
|
33
|
+
letter-spacing: 1px;
|
|
34
|
+
color: var(--ink);
|
|
35
|
+
text-decoration: none;
|
|
36
|
+
}
|
|
37
|
+
.topbar .home:hover { color: var(--muted); }
|
|
38
|
+
.topbar h1 {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font: 600 1rem/1 var(--sans);
|
|
41
|
+
color: var(--muted);
|
|
42
|
+
flex: 1;
|
|
43
|
+
}
|
|
44
|
+
.topbar .when {
|
|
45
|
+
font-size: .82rem;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
color: var(--muted);
|
|
48
|
+
flex: none;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* The four windows, as one connected control rather than four loose buttons: they are a scale
|
|
53
|
+
of how far back to look, and exactly one of them is always the answer. */
|
|
54
|
+
.periods { display: flex; flex: none; }
|
|
55
|
+
.periods button {
|
|
56
|
+
border-radius: 0;
|
|
57
|
+
border-left-width: 0;
|
|
58
|
+
padding: 5px 13px;
|
|
59
|
+
font-size: .82rem;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
color: var(--muted);
|
|
62
|
+
}
|
|
63
|
+
.periods button:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
|
|
64
|
+
.periods button:last-child { border-radius: 0 8px 8px 0; }
|
|
65
|
+
.periods button[aria-checked='true'] {
|
|
66
|
+
background: var(--ink);
|
|
67
|
+
border-color: var(--ink);
|
|
68
|
+
color: var(--paper);
|
|
69
|
+
}
|
|
70
|
+
.periods button[aria-checked='true'] + button { border-left-color: var(--ink); }
|
|
71
|
+
|
|
72
|
+
/* ── The sheet ───────────────────────────────────────────────────────── */
|
|
73
|
+
.sheet {
|
|
74
|
+
flex: 1;
|
|
75
|
+
overflow-y: auto;
|
|
76
|
+
padding: 30px 18px 80px;
|
|
77
|
+
}
|
|
78
|
+
.sheet > * { max-width: 44rem; margin-inline: auto; }
|
|
79
|
+
|
|
80
|
+
.intro { font-size: 1.08rem; }
|
|
81
|
+
.intro p { margin: 0 0 14px; }
|
|
82
|
+
.intro p.note { color: var(--muted); font-size: .95rem; }
|
|
83
|
+
.blank { color: var(--muted); text-align: center; padding: 40px 0; }
|
|
84
|
+
|
|
85
|
+
/* The repositories you have put away for this visit. Not a preference and not remembered: the
|
|
86
|
+
page is written afresh every time it is opened, so a filter that outlived it would be a
|
|
87
|
+
filter you had forgotten setting. */
|
|
88
|
+
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 6px auto 22px; }
|
|
89
|
+
.chips button {
|
|
90
|
+
border-radius: 999px;
|
|
91
|
+
padding: 4px 12px;
|
|
92
|
+
font-size: .78rem;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
font-family: var(--mono);
|
|
95
|
+
color: var(--ink);
|
|
96
|
+
}
|
|
97
|
+
.chips button[aria-pressed='false'] { color: var(--muted); background: transparent; opacity: .6; }
|
|
98
|
+
|
|
99
|
+
/* ── One repository ──────────────────────────────────────────────────── */
|
|
100
|
+
.repo {
|
|
101
|
+
background: var(--paper);
|
|
102
|
+
border: 1px solid var(--line);
|
|
103
|
+
border-radius: 12px;
|
|
104
|
+
padding: 20px 24px;
|
|
105
|
+
margin-bottom: 16px;
|
|
106
|
+
}
|
|
107
|
+
.repo h2 {
|
|
108
|
+
margin: 0 0 12px;
|
|
109
|
+
font: 700 .95rem/1 var(--mono);
|
|
110
|
+
letter-spacing: .5px;
|
|
111
|
+
}
|
|
112
|
+
.prose > :first-child { margin-top: 0; }
|
|
113
|
+
.prose > :last-child { margin-bottom: 0; }
|
|
114
|
+
.prose p { margin: 0 0 12px; }
|
|
115
|
+
/* The bold opening line of a block is the whole point of it: it says what is different now. */
|
|
116
|
+
.prose p > strong:only-child { font-size: 1.05rem; }
|
|
117
|
+
.prose ul, .prose ol { margin: 0 0 12px; padding-left: 20px; }
|
|
118
|
+
.prose li { margin-bottom: 3px; }
|
|
119
|
+
.prose code {
|
|
120
|
+
font: .85em var(--mono);
|
|
121
|
+
background: var(--bg);
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
padding: 1px 5px;
|
|
124
|
+
}
|
|
125
|
+
.prose h3 { margin: 16px 0 8px; font-size: .95rem; }
|
|
126
|
+
.prose a { color: inherit; }
|
|
127
|
+
|
|
128
|
+
/* ── The facts underneath ────────────────────────────────────────────── */
|
|
129
|
+
/* Folded away by default. The written summary is what you came for; this is what to open when
|
|
130
|
+
you do not believe it. */
|
|
131
|
+
.facts { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
|
|
132
|
+
.facts summary {
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
font-size: .78rem;
|
|
135
|
+
color: var(--muted);
|
|
136
|
+
list-style: none;
|
|
137
|
+
}
|
|
138
|
+
.facts summary::-webkit-details-marker { display: none; }
|
|
139
|
+
.facts summary::before { content: '▸ '; }
|
|
140
|
+
.facts[open] summary::before { content: '▾ '; }
|
|
141
|
+
|
|
142
|
+
.commits { list-style: none; margin: 12px 0 0; padding: 0; font-size: .88rem; }
|
|
143
|
+
.commits li { display: flex; align-items: baseline; gap: 9px; margin-bottom: 5px; }
|
|
144
|
+
.commits i {
|
|
145
|
+
flex: none;
|
|
146
|
+
width: 7px;
|
|
147
|
+
height: 7px;
|
|
148
|
+
border-radius: 999px;
|
|
149
|
+
background: var(--calm);
|
|
150
|
+
}
|
|
151
|
+
/* The one distinction the whole page is built on: out of here, or still only here. */
|
|
152
|
+
.commits i.local { background: var(--warn); }
|
|
153
|
+
|
|
154
|
+
.pending { margin: 12px 0 0; font-size: .82rem; color: var(--warn); }
|
|
155
|
+
|
|
156
|
+
/* ── While the words are being written ───────────────────────────────── */
|
|
157
|
+
.waiting { margin-bottom: 26px; }
|
|
158
|
+
.waiting .bar {
|
|
159
|
+
height: 3px;
|
|
160
|
+
background: var(--line);
|
|
161
|
+
border-radius: 2px;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
}
|
|
164
|
+
.waiting .bar i {
|
|
165
|
+
display: block;
|
|
166
|
+
height: 100%;
|
|
167
|
+
width: 32%;
|
|
168
|
+
border-radius: 2px;
|
|
169
|
+
background: var(--ink);
|
|
170
|
+
animation: sweep 1.5s ease-in-out infinite;
|
|
171
|
+
}
|
|
172
|
+
@keyframes sweep {
|
|
173
|
+
from { transform: translateX(-105%); }
|
|
174
|
+
to { transform: translateX(420%); }
|
|
175
|
+
}
|
|
176
|
+
/* Handwriting, like the other places where k0 speaks rather than reports. */
|
|
177
|
+
.waiting .phrase {
|
|
178
|
+
margin: 10px 0 0;
|
|
179
|
+
font: 600 1.25rem/1 var(--hand);
|
|
180
|
+
color: var(--muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (prefers-reduced-motion: reduce) {
|
|
184
|
+
.waiting .bar i { animation: none; width: 100%; opacity: .35; }
|
|
185
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>k0 — ChangeLog</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
8
|
+
<link rel="stylesheet" href="/changelog.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<a class="home" href="/" title="Back to the dashboard">k0</a>
|
|
13
|
+
<h1>ChangeLog</h1>
|
|
14
|
+
|
|
15
|
+
<!-- Four windows and no date picker. A summary is something you glance at, and every extra
|
|
16
|
+
control is one more decision to make before reading a word. -->
|
|
17
|
+
<div class="periods" id="periods" role="radiogroup" aria-label="Period">
|
|
18
|
+
<button type="button" role="radio" data-period="today" aria-checked="false">Today</button>
|
|
19
|
+
<button type="button" role="radio" data-period="yesterday" aria-checked="false">Yesterday</button>
|
|
20
|
+
<button type="button" role="radio" data-period="week" aria-checked="false">Week</button>
|
|
21
|
+
<button type="button" role="radio" data-period="month" aria-checked="false">Month</button>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<span class="when" id="when"></span>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<main class="sheet" id="sheet">
|
|
28
|
+
<!-- While the words are being written the facts are already here, so the page is never a
|
|
29
|
+
spinner on an empty screen: it is the summary, filling in. -->
|
|
30
|
+
<div class="waiting" id="waiting" hidden>
|
|
31
|
+
<div class="bar"><i></i></div>
|
|
32
|
+
<p class="phrase" id="phrase"></p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="intro" id="intro"></div>
|
|
36
|
+
<div class="chips" id="chips" hidden></div>
|
|
37
|
+
<div class="repos" id="repos"></div>
|
|
38
|
+
<p class="blank" id="blank" hidden></p>
|
|
39
|
+
</main>
|
|
40
|
+
|
|
41
|
+
<script type="module" src="/changelog.js"></script>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|