k0-board 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +361 -1
- package/README.md +931 -111
- package/bin/k0-board.js +79 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +30 -5
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +2 -1
- package/platform/linux/index.js +3 -0
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +5 -2
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1251 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +396 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/files.js +73 -16
- package/web/index.html +109 -12
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- package/web/whatsnew.js +339 -0
package/web/whatsnew.css
ADDED
|
@@ -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>
|
package/web/whatsnew.js
ADDED
|
@@ -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()
|