k0-board 0.1.2 → 0.3.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
@@ -96,7 +111,13 @@ function postit(card, now) {
96
111
  : ''
97
112
  }
98
113
  <div class="foot">
99
- ${dead ? '<span class="dead">session closed</span>' : ''}
114
+ ${
115
+ // Whoever shut the window, said in the same breath and the same weight — this line is
116
+ // already only an italic, and it stays one. Which of the two it is does not need its own
117
+ // place in the redraw signature: `auto_closed` never moves without `session_alive`
118
+ // moving with it, and that one is in there.
119
+ dead ? `<span class="dead">${card.auto_closed ? 'closed automatically' : 'session closed'}</span>` : ''
120
+ }
100
121
  <span class="since" title="${LABEL[card.status]} for ${since(card.status_since, now)}">${since(card.status_since, now)}</span>
101
122
  </div>
102
123
  <div class="actions"></div>`
@@ -108,10 +129,11 @@ function postit(card, now) {
108
129
  }
109
130
 
110
131
  const actions = el.querySelector('.actions')
111
- const btn = (text, fn, title) => {
132
+ const btn = (text, fn, title, cls) => {
112
133
  const b = document.createElement('button')
113
134
  b.textContent = text
114
135
  if (title) b.title = title
136
+ if (cls) b.className = cls
115
137
  b.onclick = (e) => {
116
138
  e.stopPropagation()
117
139
  fn()
@@ -127,6 +149,16 @@ function postit(card, now) {
127
149
  if (dead) btn('Resume', () => start(card.id, 'resume'))
128
150
  // An idea in the backlog cannot be "done": it never started.
129
151
  btn('Done', () => setCompleted(card.id, true), 'close this job and its terminal')
152
+ // Close gives the memory back without declaring the work over, and it comes after Done, as a
153
+ // link rather than a button: it is the rarer of the two and should not compete with it. Not
154
+ // while the session is working — stopping one mid-thought is not memory saved, it is work lost.
155
+ if (!dead && !BUSY.has(card.status))
156
+ btn(
157
+ 'Close',
158
+ () => closeSession(card.id),
159
+ 'stop the session and its terminal, and leave the card where it is',
160
+ 'link'
161
+ )
130
162
  }
131
163
 
132
164
  // The git mark is a link: a double click on it would open two tabs and bring the terminal to
@@ -148,6 +180,11 @@ const ICON = {
148
180
  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>',
149
181
  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>',
150
182
  plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
183
+ // Put this column away: it slides off to the right, where `Old` is.
184
+ 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>',
185
+ // The dev server: a world, because what is behind it is a site. A meridian and a parallel are
186
+ // enough to read it at twelve pixels — more lines and it goes back to being a circle.
187
+ 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>',
151
188
  }
152
189
 
153
190
  const esc = (s) =>
@@ -200,6 +237,95 @@ function gitChip(g, repo, card) {
200
237
  }${n}</a>`
201
238
  }
202
239
 
240
+ // ── The dev server ────────────────────────────────────────────────────
241
+ // The globe next to a repository's name: grey when its server is off, green when it is up,
242
+ // spinning while it is coming up, red when it tried and could not. One click switches it, two
243
+ // restart it, and when it is green the repository's name becomes the way into the site.
244
+ //
245
+ // The four states are the ones the server sends; nothing is guessed here. The only thing the
246
+ // page knows that the server does not is that a click has just happened and no answer has come
247
+ // back yet — which is what `switching` is for.
248
+ const switching = new Set()
249
+
250
+ /** Everything the globe knows, for the tooltip. Same idea as `gitDetail`: the detail is on hover. */
251
+ function serverDetail(s, state) {
252
+ const bits = []
253
+ if (state === 'up') bits.push(s.adopted ? `up on port ${s.port}, started outside k0` : `up on port ${s.port}`)
254
+ else if (state === 'starting') bits.push('starting…')
255
+ else if (state === 'failed') bits.push(s.error ? `it did not come up: ${s.error}` : 'it did not come up')
256
+ else bits.push('the server is off')
257
+ bits.push(s.command)
258
+ if (state === 'up') bits.push('click to stop, double-click to restart')
259
+ else if (state !== 'starting') bits.push('click to start')
260
+ // Where it went wrong is worth more than any sentence about it going wrong.
261
+ if (state === 'failed') bits.push(s.log)
262
+ return bits.join(' · ')
263
+ }
264
+
265
+ /**
266
+ * The globe, or nothing at all. A repository with no way to start a server does not get a
267
+ * button that would quietly do nothing — the server sends null and this draws none.
268
+ */
269
+ function serverChip(s, repo) {
270
+ if (!s) return ''
271
+ const state = switching.has(repo) ? 'starting' : s.state
272
+ const title = serverDetail(s, state)
273
+ const off = s.can.run ? '' : ' disabled'
274
+ return `<button class="globe ${state}" title="${esc(title)}" aria-label="${esc(title)}"${off}>${ICON.globe}</button>`
275
+ }
276
+
277
+ /**
278
+ * One click switches, two restart, and they are fighting over the same first click.
279
+ *
280
+ * What settles it without being seen: when the server is OFF there is nothing to wait for,
281
+ * because "start" and "restart" are the same thing there, so the click goes straight through.
282
+ * Only STOPPING waits the quarter second it takes to learn whether a second click is coming —
283
+ * and even that is invisible, because the globe starts spinning the moment it is pressed.
284
+ */
285
+ function wireGlobe(el, repo, s) {
286
+ if (!s.can.run) return
287
+ if (s.state === 'starting' || switching.has(repo)) {
288
+ el.disabled = true
289
+ return
290
+ }
291
+ if (s.state !== 'up') {
292
+ el.onclick = () => switchServer(repo, 'start')
293
+ return
294
+ }
295
+ let timer = null
296
+ el.onclick = () => {
297
+ if (timer) return // the second click of a double: let `ondblclick` have it
298
+ timer = setTimeout(() => {
299
+ timer = null
300
+ switchServer(repo, 'stop')
301
+ }, 250)
302
+ }
303
+ el.ondblclick = () => {
304
+ clearTimeout(timer)
305
+ timer = null
306
+ switchServer(repo, 'restart')
307
+ }
308
+ }
309
+
310
+ async function switchServer(repo, action) {
311
+ switching.add(repo)
312
+ lastSignature = '' // the globe has to start spinning now, not at the next round
313
+ await refresh()
314
+ try {
315
+ const s = await api('/api/server', { method: 'POST', body: JSON.stringify({ path: repo, action }) })
316
+ if (s.state === 'failed') toast(s.error || 'The server did not come up. Hover the globe for the log.', 8000)
317
+ } catch (err) {
318
+ toast(err.message)
319
+ } finally {
320
+ switching.delete(repo)
321
+ lastSignature = ''
322
+ refresh()
323
+ }
324
+ }
325
+
326
+ /** Just enough to compare one round with the next, exactly as `gitSig` does for git. */
327
+ const serverSig = (s) => (s ? `${s.state}/${s.port ?? ''}/${s.adopted ? 1 : 0}` : '')
328
+
203
329
  // ── What a session weighs ────────────────────────────────────────────
204
330
  /**
205
331
  * How much this session eats, counting everything it has opened underneath itself. The detail —
@@ -261,24 +387,140 @@ function render(data) {
261
387
  // still in alphabetical order — the server's, which is not touched here.
262
388
  if (data.mode === 'driving') columns.sort((a, b) => byAttention(a.cards[0], b.cards[0]))
263
389
 
264
- for (const { col, cards } of columns) {
390
+ // Which of them are still warm. Everything else folds away into `Old` — see recency.js for the
391
+ // four rules and for why the day is counted back from your last piece of work rather than from
392
+ // the clock.
393
+ const { open, alive } = split({
394
+ paths: columns.map((c) => c.col.path),
395
+ cards: data.cards,
396
+ folded,
397
+ held,
398
+ })
399
+ // A column open now is open for the rest of the visit, and a column that is open is not one
400
+ // you have put away: a session woke up in there, or you went and fetched it back yourself.
401
+ let forget = false
402
+ for (const p of open) {
403
+ held.add(p)
404
+ if (folded.delete(p)) forget = true
405
+ }
406
+ if (forget) saveFolded()
407
+
408
+ const openSet = new Set(open)
409
+ for (const { col, cards } of columns.filter(({ col }) => openSet.has(col.path))) {
265
410
  const wrap = document.createElement('section')
266
411
  wrap.className = 'column'
267
412
  // The "+" next to the name: a new card on THIS repository, without picking it.
268
413
  const add = `New card in ${esc(col.name)}`
269
- wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>${esc(
270
- col.name
271
- )}<small>${cards.length}</small>${gitChip(col.git, col.path)}</h2>`
414
+ // And next to it, the one that puts the column away without waiting for it to go quiet on
415
+ // its own. It sits there in plain sight next to the `+`, because a button you have to go
416
+ // hunting for with the pointer is a button nobody knows exists.
417
+ //
418
+ // Where the column cannot be put away it goes faint and refuses, rather than not being drawn
419
+ // at all. Not being drawn was the first idea and it was the wrong one: a column runs and
420
+ // goes back to your turn every few seconds, so the button would have spent the whole session
421
+ // blinking in and out — and it would have done it on exactly the columns you are working on.
422
+ // A control that stays where it was and says why it will not move is worth more than one
423
+ // that is only there when it agrees with you. The title carries the reason.
424
+ const live = alive.has(col.path)
425
+ 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>`
429
+ // Two groups and a name between them: what you press on the left, what tells you how things
430
+ // stand on the right. The count of post-its that used to sit after the name is gone — it
431
+ // took up the room the name now uses, and it never answered a question anybody had.
432
+ //
433
+ // With the server up, the name is the door to it. It is the right thing to click for the
434
+ // same reason the git mark is the right thing to click to reach the files: the obvious
435
+ // gesture on the obvious word, leaving the globe free to mean only on and off.
436
+ const url = col.server?.state === 'up' ? col.server.url : null
437
+ const name = url
438
+ ? `<a class="name" href="${esc(url)}" target="_blank" title="${esc(url)}">${esc(col.name)}</a>`
439
+ : `<span class="name">${esc(col.name)}</span>`
440
+ wrap.innerHTML = `<h2><button class="add" title="${add}" aria-label="${add}">${
441
+ ICON.plus
442
+ }</button>${fold}${name}${gitChip(col.git, col.path)}${serverChip(col.server, col.path)}</h2>`
272
443
  wrap.querySelector('.add').onclick = () => openEditor(null, col.path)
444
+ wrap.querySelector('.fold').onclick = () => putAway(col.path)
445
+ const globe = wrap.querySelector('.globe')
446
+ if (globe) wireGlobe(globe, col.path, col.server)
273
447
  for (const c of cards) wrap.append(postit(c, data.now))
274
448
  board.append(wrap)
275
449
  }
276
450
 
451
+ board.append(oldColumn(columns.filter(({ col }) => !openSet.has(col.path))))
277
452
  board.append(repoColumn(data.projects ?? [], new Set(columns.map((c) => c.col.path))))
278
453
  $('#empty').style.display = columns.length ? 'none' : 'grid'
279
454
  refit() // the board changed size: the view comes back inside its limits
280
455
  }
281
456
 
457
+ /** Put a column away by hand, and let go of the place it was holding for this visit. */
458
+ function putAway(path) {
459
+ folded.add(path)
460
+ held.delete(path)
461
+ saveFolded()
462
+ lastSignature = ''
463
+ refresh()
464
+ }
465
+
466
+ /** Fetch one back: it stays for the rest of the visit, and folds again on the next load. */
467
+ function bringBack(path) {
468
+ held.add(path)
469
+ folded.delete(path)
470
+ saveFolded()
471
+ lastSignature = ''
472
+ refresh()
473
+ }
474
+
475
+ /**
476
+ * The second-to-last column: the repositories that DO have work on the board, just not lately —
477
+ * nothing touched in the day before your most recent piece of work, and nothing alive inside.
478
+ * They are the reason the board was three screens wide: a dozen of them, standing at full width
479
+ * between the three you are actually on.
480
+ *
481
+ * They are folded, not thrown away. Click the row and the column comes back at full width for the
482
+ * rest of the visit.
483
+ *
484
+ * 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
486
+ * in here once, and both had to go — three numbers on a row you are meant to read at a glance is
487
+ * two too many, and the answer to either is one click away in the column itself.
488
+ */
489
+ function oldColumn(columns) {
490
+ if (!columns.length) return document.createDocumentFragment()
491
+
492
+ const wrap = document.createElement('section')
493
+ wrap.className = 'column repos old'
494
+ // Alphabetical, for the same reason `Others` is: these are rows you scan with your eye looking
495
+ // for a name. In driving mode the columns above have queued up by urgency, and letting that
496
+ // order through to here would be a list that reshuffles itself with nothing urgent in it.
497
+ wrap.innerHTML =
498
+ '<h2>Old<small>' +
499
+ columns.length +
500
+ '</small></h2>' +
501
+ [...columns]
502
+ .sort((a, b) => a.col.name.localeCompare(b.col.name, undefined, { sensitivity: 'base' }))
503
+ .map(({ col }) => {
504
+ const add = `New card in ${esc(col.name)}`
505
+ const back = `Bring ${col.name} back onto the board`
506
+ return `<div class="repo" data-p="${esc(col.path)}">
507
+ <button class="add" title="${add}" aria-label="${add}">${ICON.plus}</button>
508
+ <button class="name" title="${esc(back)}">${esc(col.name)}</button>${gitChip(col.git, col.path)}
509
+ </div>`
510
+ })
511
+ .join('')
512
+ // The whole row brings the column back, not only the name: at this size, asking for the four
513
+ // words exactly is asking for a second click. The `+` and the git lens are the two things in
514
+ // there that mean something else, and they keep meaning it.
515
+ wrap.onclick = (e) => {
516
+ const path = e.target.closest('.repo')?.dataset.p
517
+ if (!path || e.target.closest('.git')) return
518
+ if (e.target.closest('.add')) openEditor(null, path)
519
+ else bringBack(path)
520
+ }
521
+ return wrap
522
+ }
523
+
282
524
  /**
283
525
  * The last column: the OTHER repositories, the ones with no column because they have not one
284
526
  * card between them. Repeating the ones already on the left would be saying the same thing
@@ -323,7 +565,7 @@ function repoColumn(projects, shown) {
323
565
  * is nearly always. The colour is the kernel's judgement, not a threshold invented here: green
324
566
  * while it says all is well, amber at the first warning, red when it is critical.
325
567
  */
326
- function renderMachine(m, cards) {
568
+ function renderMachine(m, cards, idleHours = 0) {
327
569
  const el = $('#gauge')
328
570
  if (!m?.mem) return el.setAttribute('hidden', '') // the first sample is not in yet
329
571
  el.removeAttribute('hidden')
@@ -331,7 +573,12 @@ function renderMachine(m, cards) {
331
573
 
332
574
  const ram = Math.round((m.mem.used / m.mem.total) * 100)
333
575
  const cpu = m.cpu === null ? '—' : `${Math.round(m.cpu * 100)}%`
334
- el.innerHTML = `<i></i>RAM ${ram}% · CPU ${cpu}`
576
+ // Whether anybody is looking after the memory for you, and after how long. It belongs on this
577
+ // chip and nowhere else: this is where the memory is already being talked about, and a thing
578
+ // that quietly closes your windows should not be something you have to know about beforehand.
579
+ // 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}`
335
582
 
336
583
  const top = cards.find((c) => c.id === heaviest)
337
584
  const lines = [
@@ -341,6 +588,13 @@ function renderMachine(m, cards) {
341
588
  if (top?.load) lines.push(`heaviest: ${top.title} — ${weight(top.load.rss)}`)
342
589
  // What is heavy and is not k0: without it, the board would take blame that belongs to Chrome.
343
590
  if (m.others?.length) lines.push(`outside k0: ${m.others.map((o) => `${o.name} ${gb(o.rss)}`).join(' · ')}`)
591
+ // The chip has room for three words; the sentence that explains them goes here, and it is also
592
+ // the only place that can say the closing is switched off — the chip says that by staying quiet.
593
+ lines.push(
594
+ idleHours > 0
595
+ ? `a terminal nobody touches for ${idleHours}h is closed and its memory given back — the card stays, Resume picks it up`
596
+ : 'closing forgotten terminals is switched off'
597
+ )
344
598
  lines.push(showLoad ? 'click to hide the weight on each card' : 'click to show the weight on each card')
345
599
  el.title = lines.join('\n')
346
600
  }
@@ -406,6 +660,21 @@ async function remove(card) {
406
660
  refresh()
407
661
  }
408
662
 
663
+ /**
664
+ * The terminal goes, the card stays. Stopping the session takes a couple of seconds — it is asked
665
+ * to leave and given the time to — so it says what it is doing while it waits.
666
+ */
667
+ async function closeSession(id) {
668
+ toast('Closing the terminal…', 10000)
669
+ try {
670
+ await api(`/api/card/${id}/close`, { method: 'POST' })
671
+ toast('Closed. Resume picks the conversation up where it was')
672
+ } catch (e) {
673
+ toast(`Couldn't do it: ${e.message}`, 8000)
674
+ }
675
+ refresh()
676
+ }
677
+
409
678
  async function setCompleted(id, completed) {
410
679
  await api(`/api/card/${id}`, { method: 'PATCH', body: JSON.stringify({ completed }) })
411
680
  refresh()
@@ -606,7 +875,7 @@ async function refresh() {
606
875
  // What is eating most right now: it is what tints that one red and nobody else.
607
876
  heaviest =
608
877
  data.cards.filter((c) => c.load).sort((a, b) => b.load.rss - a.load.rss)[0]?.id ?? null
609
- renderMachine(machine, data.cards)
878
+ renderMachine(machine, data.cards, data.closeIdleAfterHours)
610
879
 
611
880
  // Redraw only if something really changed, so the editor and the focus stay where they are.
612
881
  // The git state is inside the signature: without it, the mark would stay the one from the
@@ -627,10 +896,16 @@ async function refresh() {
627
896
  c.description,
628
897
  c.prompt,
629
898
  c.project_path,
899
+ // What folds a column away and what brings it back: the day is measured from the
900
+ // freshest of these, so when none of them moves nothing can fold, and the board is
901
+ // spared a redraw on a timer.
902
+ c.updated_at,
630
903
  gitSig(c.git),
631
904
  c.load ? weight(c.load.rss) : '',
632
905
  ]),
633
- data.columns.map((col) => [col.path, gitSig(col.git)]),
906
+ // The server state belongs in here for the same reason the git state does: without it the
907
+ // globe would keep the colour it had on the first round for the rest of the visit.
908
+ data.columns.map((col) => [col.path, gitSig(col.git), serverSig(col.server)]),
634
909
  (data.projects ?? []).map((p) => [p.path, gitSig(p.git)]),
635
910
  ])
636
911
  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>