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/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
- ${card.description ? `<div class="desc">${esc(card.description)}</div>` : ''}
88
- ${card.prompt ? `<div class="prompt">${esc(card.prompt)}</div>` : ''}
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
- for (const { col, cards } of columns) {
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
- wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>${esc(
261
- col.name
262
- )}<small>${cards.length}</small>${gitChip(col.git, col.path)}</h2>`
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
- data.columns.map((col) => [col.path, gitSig(col.git)]),
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>