k0-board 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +426 -1
  21. package/LICENSE +83 -18
  22. package/README.md +1141 -119
  23. package/bin/k0-board.js +127 -12
  24. package/menubar/K0MenuBar.swift +35 -5
  25. package/package.json +14 -2
  26. package/platform/contract.js +30 -5
  27. package/platform/darwin/terminal.js +230 -56
  28. package/platform/index.js +2 -1
  29. package/platform/linux/index.js +3 -0
  30. package/platform/linux/tray.py +10 -10
  31. package/platform/shared/command.js +19 -3
  32. package/platform/shared/tmux.js +32 -14
  33. package/platform/win32/index.js +5 -2
  34. package/platform/win32/terminal.js +24 -8
  35. package/platform/win32/tray.ps1 +13 -13
  36. package/server/backlog.js +618 -0
  37. package/server/changelog.js +13 -13
  38. package/server/cloud.js +355 -0
  39. package/server/db.js +2117 -146
  40. package/server/files.js +60 -21
  41. package/server/git.js +53 -14
  42. package/server/idle.js +15 -13
  43. package/server/index.js +1341 -186
  44. package/server/launcher.js +122 -14
  45. package/server/machine.js +10 -1
  46. package/server/mirror.js +1069 -0
  47. package/server/prices.js +59 -0
  48. package/server/projects.js +57 -12
  49. package/server/servers.js +22 -1
  50. package/server/sessions.js +5 -5
  51. package/server/settings.js +25 -2
  52. package/server/sync.js +383 -0
  53. package/server/update.js +325 -0
  54. package/server/usage.js +642 -0
  55. package/server/watcher.js +67 -13
  56. package/server/worktree.js +600 -0
  57. package/server/writer.js +98 -26
  58. package/web/account.js +265 -0
  59. package/web/awake.js +62 -0
  60. package/web/base.css +97 -0
  61. package/web/board.css +396 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/files.js +73 -16
  65. package/web/index.html +110 -12
  66. package/web/list.css +536 -0
  67. package/web/list.js +1015 -0
  68. package/web/mentions.js +27 -9
  69. package/web/recency.js +10 -10
  70. package/web/units.js +1 -1
  71. package/web/usage.css +169 -0
  72. package/web/usage.html +78 -0
  73. package/web/usage.js +447 -0
  74. package/web/view.js +22 -3
  75. package/web/whatsnew.css +211 -0
  76. package/web/whatsnew.html +49 -0
  77. package/web/whatsnew.js +339 -0
@@ -0,0 +1,211 @@
1
+ /* What's New: what changed between the version you had and the one you are running.
2
+ Colours and fonts live in base.css.
3
+
4
+ It is the ChangeLog's twin — a sheet of paper you read top to bottom rather than a board you
5
+ look at — so it borrows that page's measure, its top bar and its waiting bar deliberately.
6
+ Two pages that do the same thing should not look like two different products. */
7
+ @import url('/base.css');
8
+
9
+ body {
10
+ margin: 0;
11
+ background: var(--bg);
12
+ color: var(--ink);
13
+ font-family: var(--sans);
14
+ font-size: 1rem;
15
+ line-height: 1.55;
16
+ -webkit-font-smoothing: antialiased;
17
+ display: flex;
18
+ flex-direction: column;
19
+ height: 100%;
20
+ }
21
+
22
+ /* ── The top bar ─────────────────────────────────────────────────────── */
23
+ .topbar {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 14px;
27
+ padding: 10px 18px;
28
+ background: var(--paper);
29
+ border-bottom: 1px solid var(--line);
30
+ flex: none;
31
+ /* Four things in a row, and on a narrow window they wrap rather than squeeze the title out. */
32
+ flex-wrap: wrap;
33
+ }
34
+ .topbar .home {
35
+ font: 700 1.2rem/1 var(--mono);
36
+ letter-spacing: 1px;
37
+ color: var(--ink);
38
+ text-decoration: none;
39
+ }
40
+ .topbar .home:hover { color: var(--muted); }
41
+ .topbar h1 {
42
+ margin: 0;
43
+ font: 600 1rem/1 var(--sans);
44
+ color: var(--muted);
45
+ flex: 1;
46
+ }
47
+
48
+ /* The language sits next to the levels and has to look like it belongs with them, so it takes
49
+ the house button: base.css styles buttons, not selects. */
50
+ .topbar select {
51
+ font: inherit;
52
+ font-size: .82rem;
53
+ font-weight: 600;
54
+ color: var(--muted);
55
+ border: 1px solid var(--line);
56
+ background: var(--paper);
57
+ border-radius: 8px;
58
+ padding: 5px 9px;
59
+ cursor: pointer;
60
+ flex: none;
61
+ }
62
+ .topbar select:hover { background: #fff; border-color: #c9c3b4; }
63
+
64
+ /* The three levels, as one connected control rather than three loose buttons: they are a scale
65
+ of how much you want to be told, and exactly one of them is always the answer. */
66
+ .levels { display: flex; flex: none; }
67
+ .levels button {
68
+ border-radius: 0;
69
+ border-left-width: 0;
70
+ padding: 5px 13px;
71
+ font-size: .82rem;
72
+ font-weight: 600;
73
+ color: var(--muted);
74
+ }
75
+ .levels button:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
76
+ .levels button:last-child { border-radius: 0 8px 8px 0; }
77
+ .levels button[aria-checked='true'] {
78
+ background: var(--ink);
79
+ border-color: var(--ink);
80
+ color: var(--paper);
81
+ }
82
+ .levels button[aria-checked='true'] + button { border-left-color: var(--ink); }
83
+
84
+ /* ── The sheet ───────────────────────────────────────────────────────── */
85
+ .sheet {
86
+ flex: 1;
87
+ overflow-y: auto;
88
+ padding: 30px 18px 80px;
89
+ }
90
+ .sheet > * { max-width: 44rem; margin-inline: auto; }
91
+
92
+ /* The sentence about the two versions. It is the first thing read and the only thing on the
93
+ page that is always there, so it carries a little more weight than the prose under it. */
94
+ .span { margin-bottom: 24px; }
95
+ .span p { margin: 0; font-size: 1.08rem; }
96
+ .span .also { margin-top: 5px; font-size: .9rem; color: var(--muted); }
97
+
98
+ .note {
99
+ margin: 0;
100
+ color: var(--muted);
101
+ font-size: .95rem;
102
+ }
103
+
104
+ /* ── The page the model wrote ────────────────────────────────────────── */
105
+ .prose > :first-child { margin-top: 0; }
106
+ .prose > :last-child { margin-bottom: 0; }
107
+ .prose h2 { margin: 0 0 14px; font: 700 1.35rem/1.25 var(--sans); }
108
+ .prose h3 { margin: 28px 0 8px; font: 600 1.06rem/1.3 var(--sans); }
109
+ .prose h4 { margin: 20px 0 6px; font: 600 .95rem/1.3 var(--sans); }
110
+ .prose p { margin: 0 0 13px; }
111
+ .prose ul, .prose ol { margin: 0 0 13px; padding-left: 20px; }
112
+ .prose li { margin-bottom: 4px; }
113
+ .prose code {
114
+ font: .85em var(--mono);
115
+ background: var(--paper);
116
+ border: 1px solid var(--line);
117
+ border-radius: 4px;
118
+ padding: 1px 5px;
119
+ }
120
+ .prose pre {
121
+ background: var(--paper);
122
+ border: 1px solid var(--line);
123
+ border-radius: 10px;
124
+ padding: 12px 14px;
125
+ overflow-x: auto;
126
+ }
127
+ .prose pre code { border: none; padding: 0; background: none; }
128
+ .prose blockquote {
129
+ margin: 0 0 13px;
130
+ padding-left: 14px;
131
+ border-left: 2px solid var(--line);
132
+ color: var(--muted);
133
+ }
134
+ .prose a { color: inherit; }
135
+
136
+ /* ── The entries underneath ──────────────────────────────────────────── */
137
+ /* Open while there is nothing else on the page, because a fold you have to click to find
138
+ something to read is a blank screen with extra steps. */
139
+ .entries {
140
+ background: var(--paper);
141
+ border: 1px solid var(--line);
142
+ border-radius: 12px;
143
+ padding: 14px 24px;
144
+ margin-top: 30px;
145
+ }
146
+ .entries summary {
147
+ cursor: pointer;
148
+ font-size: .78rem;
149
+ color: var(--muted);
150
+ list-style: none;
151
+ }
152
+ .entries summary::-webkit-details-marker { display: none; }
153
+ .entries summary::before { content: '▸ '; }
154
+ .entries[open] summary::before { content: '▾ '; }
155
+
156
+ .version { padding-top: 16px; }
157
+ .version + .version {
158
+ margin-top: 16px;
159
+ border-top: 1px dashed var(--line);
160
+ }
161
+ .version h3 {
162
+ display: flex;
163
+ align-items: baseline;
164
+ gap: 10px;
165
+ margin: 0;
166
+ font: 700 1rem/1 var(--mono);
167
+ }
168
+ .version h3 time { font: 400 .78rem/1 var(--sans); color: var(--muted); }
169
+ /* The section names are a filing label, not a heading: small, quiet, and out of the way of the
170
+ lines, which are the part anybody actually reads. */
171
+ .version h4 {
172
+ margin: 14px 0 4px;
173
+ font: 600 .72rem/1 var(--sans);
174
+ text-transform: uppercase;
175
+ letter-spacing: .09em;
176
+ color: var(--muted);
177
+ }
178
+ .version ul { margin: 0; padding-left: 20px; font-size: .92rem; }
179
+ .version li { margin-bottom: 4px; }
180
+ .version .empty { margin: 12px 0 0; font-size: .9rem; color: var(--muted); }
181
+
182
+ /* ── While the words are being written ───────────────────────────────── */
183
+ .waiting { margin-bottom: 26px; }
184
+ .waiting .bar {
185
+ height: 3px;
186
+ background: var(--line);
187
+ border-radius: 2px;
188
+ overflow: hidden;
189
+ }
190
+ .waiting .bar i {
191
+ display: block;
192
+ height: 100%;
193
+ width: 32%;
194
+ border-radius: 2px;
195
+ background: var(--ink);
196
+ animation: sweep 1.5s ease-in-out infinite;
197
+ }
198
+ @keyframes sweep {
199
+ from { transform: translateX(-105%); }
200
+ to { transform: translateX(420%); }
201
+ }
202
+ /* Handwriting, like the other places where k0 speaks rather than reports. */
203
+ .waiting .phrase {
204
+ margin: 10px 0 0;
205
+ font: 600 1.25rem/1 var(--hand);
206
+ color: var(--muted);
207
+ }
208
+
209
+ @media (prefers-reduced-motion: reduce) {
210
+ .waiting .bar i { animation: none; width: 100%; opacity: .35; }
211
+ }
@@ -0,0 +1,49 @@
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 — What's New</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <link rel="stylesheet" href="/whatsnew.css">
9
+ </head>
10
+ <body>
11
+ <header class="topbar">
12
+ <a class="home" href="/" title="Back to the dashboard">k0</a>
13
+ <h1>What's New</h1>
14
+
15
+ <!-- Two controls, and both of them change who the page is being written for rather than
16
+ what is in it. Both send it back to the model, and both are remembered: coming back to
17
+ this page to find it in a language you do not read is the whole page wasted. -->
18
+ <select id="lang" aria-label="Language"></select>
19
+
20
+ <div class="levels" id="levels" role="radiogroup" aria-label="How technical">
21
+ <button type="button" role="radio" data-level="plain" aria-checked="false">Plain</button>
22
+ <button type="button" role="radio" data-level="normal" aria-checked="false">Normal</button>
23
+ <button type="button" role="radio" data-level="nerd" aria-checked="false">Nerd</button>
24
+ </div>
25
+ </header>
26
+
27
+ <main class="sheet" id="sheet">
28
+ <!-- Which two versions this page stands between, said the way you would say it out loud. -->
29
+ <div class="span" id="span"></div>
30
+
31
+ <div class="waiting" id="waiting" hidden>
32
+ <div class="bar"><i></i></div>
33
+ <p class="phrase" id="phrase"></p>
34
+ </div>
35
+
36
+ <div class="prose" id="prose"></div>
37
+ <p class="note" id="note" hidden></p>
38
+
39
+ <!-- The changelog itself, on screen from the first moment. While the words are being written
40
+ it is what there is to read; afterwards it is what you check them against. -->
41
+ <details class="entries" id="entries" open>
42
+ <summary>The entries, exactly as they were written</summary>
43
+ <div class="versions" id="versions"></div>
44
+ </details>
45
+ </main>
46
+
47
+ <script type="module" src="/whatsnew.js"></script>
48
+ </body>
49
+ </html>
@@ -0,0 +1,339 @@
1
+ import { render } from '/md.js'
2
+
3
+ // ── What's New ───────────────────────────────────────────────────────────────
4
+ // k0 moved on while you were not looking. This is the page that says what it did — written by
5
+ // your own Claude, in your language, at the level of detail you asked for.
6
+ //
7
+ // It is built the way the ChangeLog page is, and for the same reason: the entries come back
8
+ // from the server at once and are on screen before you have finished reading the heading, while
9
+ // the words take a model half a minute. So there is never a spinner over an empty sheet. At
10
+ // worst this page is a changelog, which is what everybody else ships and what people have been
11
+ // reading for twenty years.
12
+
13
+ const $ = (s) => document.querySelector(s)
14
+
15
+ const api = async (url, opts) => {
16
+ const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
17
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText)
18
+ return r.json()
19
+ }
20
+
21
+ const LEVELS = ['plain', 'normal', 'nerd']
22
+
23
+ // Not a claim about what the model can write — it will write in anything — but a list short
24
+ // enough to pick from without scrolling. Whatever the browser is set to is added to it, so the
25
+ // one language that matters to the person reading is never the missing one.
26
+ const LANGS = ['en', 'it', 'es', 'fr', 'de', 'pt', 'nl', 'pl', 'sv', 'ru', 'tr', 'ja', 'zh']
27
+
28
+ // Keep a Changelog's order, which is the order a changelog is read in everywhere. A section
29
+ // under some other heading is kept and shown after these rather than dropped: somebody wrote it.
30
+ const SECTIONS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
31
+
32
+ // Something to read while the model reads. They say what is actually going on, which is the
33
+ // only reason a waiting message is ever worth having.
34
+ const PHRASES = [
35
+ 'Reading the changelog…',
36
+ 'Working out what you will actually notice…',
37
+ 'Putting the lines that are about one thing together…',
38
+ 'Leaving out what changed nothing for you…',
39
+ 'Putting it in your own words…',
40
+ ]
41
+
42
+ let facts = null
43
+ let poll = null
44
+ let phrases = null
45
+ let quiet = 0 // rounds in a row with no job running and nothing written
46
+ let touched = false // the reader opened or closed the entries by hand
47
+
48
+ // Bumped every time the controls change. A reply to the question before this one is thrown
49
+ // away: it is a whole page in the wrong language, and it would arrive looking authoritative.
50
+ let asking = 0
51
+
52
+ // ── What was asked for ───────────────────────────────────────────────────────
53
+ const clean = (tag) => (/^[a-z]{2,3}$/.test(String(tag || '').toLowerCase()) ? String(tag).toLowerCase() : '')
54
+
55
+ let lang = clean(localStorage.getItem('k0-whatsnew-lang')) || clean((navigator.language || '').split('-')[0]) || 'en'
56
+ let level = LEVELS.includes(localStorage.getItem('k0-whatsnew-level'))
57
+ ? localStorage.getItem('k0-whatsnew-level')
58
+ : 'normal'
59
+
60
+ /**
61
+ * The pair goes on the query string of the POST as well as in its body, and on the GET too,
62
+ * because the writer keeps one job per key: the page has to say which job it is asking about on
63
+ * the way in and on the way back, or a language switched mid-run polls the old run and paints
64
+ * its answer.
65
+ */
66
+ // `from` is the version the page is actually showing, sent back rather than left to the server to
67
+ // work out a second time. Marking the page read moves the mark the server would compute it from,
68
+ // and without this a language chosen after that would be answered with a summary of a shorter span
69
+ // than the entries printed underneath it.
70
+ const writeUrl = () =>
71
+ `/api/whatsnew/write?lang=${encodeURIComponent(lang)}&level=${encodeURIComponent(level)}` +
72
+ `&from=${encodeURIComponent(facts?.from ?? '')}`
73
+
74
+ function fillLangs() {
75
+ const names = new Intl.DisplayNames(['en'], { type: 'language', fallback: 'code' })
76
+ const sel = $('#lang')
77
+ for (const tag of LANGS.includes(lang) ? LANGS : [lang, ...LANGS]) {
78
+ const o = document.createElement('option')
79
+ o.value = tag
80
+ o.textContent = names.of(tag)
81
+ sel.append(o)
82
+ }
83
+ sel.value = lang
84
+ }
85
+
86
+ function markLevel() {
87
+ for (const b of document.querySelectorAll('#levels button')) {
88
+ b.setAttribute('aria-checked', String(b.dataset.level === level))
89
+ }
90
+ }
91
+
92
+ // ── Painting ─────────────────────────────────────────────────────────────────
93
+ /**
94
+ * A date written `2026-09-02` goes through the Date constructor as midnight UTC, which prints as
95
+ * the day before in every timezone behind Greenwich. The three numbers are pulled out by hand
96
+ * instead, and a date in any other shape is shown exactly as it arrived.
97
+ */
98
+ function niceDate(iso) {
99
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''))
100
+ if (!m) return String(iso || '')
101
+ return new Date(+m[1], +m[2] - 1, +m[3]).toLocaleDateString(undefined, {
102
+ day: 'numeric',
103
+ month: 'long',
104
+ year: 'numeric',
105
+ })
106
+ }
107
+
108
+ /** The two versions as a sentence. A page that opens on `0.3.1 → 0.4.0` is a diff, not a page. */
109
+ function spanLine(d) {
110
+ const n = d.entries?.length || 0
111
+ if (!d.to) return 'k0 does not know which version it is running.'
112
+ if (!d.from) return `You are running k0 ${d.to}. There is no earlier version to hold it against yet.`
113
+ if (d.from === d.to) return `You are still on k0 ${d.to}, the version you had the last time you looked.`
114
+ const later = n > 1 ? `, ${n} versions later` : ''
115
+ return `You had k0 ${d.from}. You are running ${d.to} now${later}.`
116
+ }
117
+
118
+ function paintSpan(d) {
119
+ const box = $('#span')
120
+ box.textContent = ''
121
+ const p = document.createElement('p')
122
+ p.textContent = spanLine(d)
123
+ box.append(p)
124
+ // Only when the server has said so: this page is also reached from the mark that appears when
125
+ // npm has something newer, and in that case the two versions on their own explain nothing.
126
+ if (d.latest && d.latest !== d.to) {
127
+ const also = document.createElement('p')
128
+ also.className = 'also'
129
+ also.textContent = `npm has ${d.latest}, which is newer than what is installed here.`
130
+ box.append(also)
131
+ }
132
+ if (d.to) document.title = `k0 — What's New in ${d.to}`
133
+ }
134
+
135
+ function versionBlock(e) {
136
+ const art = document.createElement('article')
137
+ art.className = 'version'
138
+
139
+ const h = document.createElement('h3')
140
+ h.textContent = e.version || ''
141
+ if (e.date) {
142
+ const t = document.createElement('time')
143
+ t.textContent = niceDate(e.date)
144
+ h.append(t)
145
+ }
146
+ art.append(h)
147
+
148
+ const sections = e.sections || {}
149
+ const has = (name) => Array.isArray(sections[name]) && sections[name].length
150
+ const names = [...SECTIONS.filter(has), ...Object.keys(sections).filter((s) => !SECTIONS.includes(s) && has(s))]
151
+
152
+ if (!names.length) {
153
+ const p = document.createElement('p')
154
+ p.className = 'empty'
155
+ p.textContent = 'Nothing written down under this version.'
156
+ art.append(p)
157
+ return art
158
+ }
159
+
160
+ for (const name of names) {
161
+ const h4 = document.createElement('h4')
162
+ h4.textContent = name
163
+ const ul = document.createElement('ul')
164
+ for (const line of sections[name]) {
165
+ const li = document.createElement('li')
166
+ li.textContent = line // a changelog line is a line, not markup: it goes in as it was typed
167
+ ul.append(li)
168
+ }
169
+ art.append(h4, ul)
170
+ }
171
+ return art
172
+ }
173
+
174
+ function paintEntries(d) {
175
+ const box = $('#versions')
176
+ box.textContent = ''
177
+ for (const e of d.entries || []) box.append(versionBlock(e))
178
+ $('#entries').hidden = !(d.entries || []).length
179
+ }
180
+
181
+ function paintProse(text) {
182
+ const box = $('#prose')
183
+ box.textContent = ''
184
+ if (!text) return
185
+ box.innerHTML = render(text)
186
+ // The entries were what there was to read while the waiting went on; now there is a page, so
187
+ // they step back and become the thing you check it against. Unless they were opened or closed
188
+ // by hand, in which case that stands — nothing moves under the reader's own decision.
189
+ if (!touched) $('#entries').open = false
190
+ }
191
+
192
+ function note(message) {
193
+ const p = $('#note')
194
+ p.textContent = message
195
+ p.hidden = !message
196
+ }
197
+
198
+ // ── Waiting ──────────────────────────────────────────────────────────────────
199
+ function waiting(on) {
200
+ $('#waiting').hidden = !on
201
+ clearInterval(phrases)
202
+ if (!on) return
203
+ let i = 0
204
+ $('#phrase').textContent = PHRASES[0]
205
+ phrases = setInterval(() => {
206
+ i = (i + 1) % PHRASES.length
207
+ $('#phrase').textContent = PHRASES[i]
208
+ }, 2600)
209
+ }
210
+
211
+ function stop() {
212
+ clearInterval(poll)
213
+ poll = null
214
+ waiting(false)
215
+ }
216
+
217
+ /** True once there is nothing left to wait for. */
218
+ function arrived(state) {
219
+ if (state.text) {
220
+ stop()
221
+ paintProse(state.text)
222
+ return true
223
+ }
224
+ if (state.error) {
225
+ stop()
226
+ note(state.error)
227
+ return true
228
+ }
229
+ if (state.running) {
230
+ quiet = 0
231
+ return false
232
+ }
233
+ // Nothing running, nothing written and nothing gone wrong: the job died on its way out and
234
+ // nobody is coming back with a page. Two rounds of that and this stops asking, because the
235
+ // alternative is a bar that sweeps until the tab is closed.
236
+ quiet += 1
237
+ if (quiet < 2) return false
238
+ stop()
239
+ note('The writing stopped before it got anywhere. The entries are below, as they were written.')
240
+ return true
241
+ }
242
+
243
+ async function writeUp() {
244
+ const mine = ++asking
245
+ quiet = 0
246
+ note('')
247
+ paintProse('')
248
+ waiting(true)
249
+
250
+ try {
251
+ const started = await api(writeUrl(), { method: 'POST', body: JSON.stringify({ lang, level }) })
252
+ if (mine !== asking) return
253
+ if (arrived(started)) return
254
+ } catch (err) {
255
+ if (mine !== asking) return
256
+ stop()
257
+ note(String(err.message || err))
258
+ return
259
+ }
260
+
261
+ poll = setInterval(async () => {
262
+ if (mine !== asking) return
263
+ try {
264
+ const state = await api(writeUrl())
265
+ if (mine === asking) arrived(state)
266
+ } catch {
267
+ /* the server is restarting: the next round will find it */
268
+ }
269
+ }, 1000)
270
+ }
271
+
272
+ // ── Loading ──────────────────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * The mark on the board goes out, and this is the only thing that puts it out.
276
+ *
277
+ * Here rather than on the way out of the tab: closing a tab is not the same as having read what
278
+ * was in it, and the server's own comment says so. It goes after the entries are painted, because
279
+ * by then what changed is on the screen — the write-up says it better, but the entries are the
280
+ * substance and they are already readable. Nothing waits for this and nothing depends on it: a
281
+ * page that could not tell the server it had been read is still a page that was read.
282
+ */
283
+ const markSeen = () => api('/api/whatsnew/seen', { method: 'POST' }).catch(() => {})
284
+
285
+ async function load() {
286
+ stop()
287
+ try {
288
+ facts = await api('/api/whatsnew')
289
+ } catch (err) {
290
+ note(String(err.message || err))
291
+ return
292
+ }
293
+
294
+ paintSpan(facts)
295
+ paintEntries(facts)
296
+ markSeen()
297
+
298
+ if (!(facts.entries || []).length) {
299
+ // Nothing to summarise. Saying so costs a line; asking a model to say so costs half a minute
300
+ // and comes back with the same line.
301
+ note('Nothing changed between the two versions.')
302
+ return
303
+ }
304
+ if (!facts.writer?.can) {
305
+ note(`${facts.writer?.why || 'Claude Code is not on this machine.'} The entries are below, as they were written.`)
306
+ return
307
+ }
308
+ writeUp()
309
+ }
310
+
311
+ /** A control moved: the same facts, the same page, asked for again. */
312
+ function again() {
313
+ if (!facts || !(facts.entries || []).length || !facts.writer?.can) return
314
+ stop()
315
+ writeUp()
316
+ }
317
+
318
+ $('#levels').onclick = (e) => {
319
+ const b = e.target.closest('button[data-level]')
320
+ if (!b || b.dataset.level === level) return
321
+ level = b.dataset.level
322
+ localStorage.setItem('k0-whatsnew-level', level)
323
+ markLevel()
324
+ again()
325
+ }
326
+
327
+ $('#lang').onchange = () => {
328
+ lang = $('#lang').value
329
+ localStorage.setItem('k0-whatsnew-lang', lang)
330
+ again()
331
+ }
332
+
333
+ $('#entries').querySelector('summary').onclick = () => {
334
+ touched = true
335
+ }
336
+
337
+ fillLangs()
338
+ markLevel()
339
+ load()