k0-board 0.1.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 (67) hide show
  1. package/.claude/skills/k0-import/SKILL.md +83 -0
  2. package/CHANGELOG.md +57 -0
  3. package/LICENSE +21 -0
  4. package/README.md +688 -0
  5. package/bin/k0-board.js +456 -0
  6. package/bin/prompt.js +41 -0
  7. package/menubar/Info.plist +25 -0
  8. package/menubar/K0MenuBar.swift +561 -0
  9. package/menubar/build.sh +28 -0
  10. package/package.json +45 -0
  11. package/platform/contract.js +134 -0
  12. package/platform/darwin/extras.js +254 -0
  13. package/platform/darwin/index.js +24 -0
  14. package/platform/darwin/metrics.js +59 -0
  15. package/platform/darwin/power.js +133 -0
  16. package/platform/darwin/service.js +143 -0
  17. package/platform/darwin/shell.js +59 -0
  18. package/platform/darwin/terminal.js +324 -0
  19. package/platform/index.js +97 -0
  20. package/platform/linux/index.js +60 -0
  21. package/platform/linux/metrics.js +88 -0
  22. package/platform/linux/power.js +137 -0
  23. package/platform/linux/service.js +111 -0
  24. package/platform/linux/shell.js +97 -0
  25. package/platform/linux/tray.py +229 -0
  26. package/platform/shared/command.js +26 -0
  27. package/platform/shared/ps.js +40 -0
  28. package/platform/shared/run.js +99 -0
  29. package/platform/shared/tmux.js +242 -0
  30. package/platform/win32/index.js +53 -0
  31. package/platform/win32/metrics.js +72 -0
  32. package/platform/win32/power.js +109 -0
  33. package/platform/win32/powershell.js +44 -0
  34. package/platform/win32/service.js +111 -0
  35. package/platform/win32/shell.js +64 -0
  36. package/platform/win32/terminal.js +209 -0
  37. package/platform/win32/tray.ps1 +159 -0
  38. package/server/db.js +262 -0
  39. package/server/files.js +488 -0
  40. package/server/git.js +157 -0
  41. package/server/index.js +636 -0
  42. package/server/launcher.js +121 -0
  43. package/server/machine.js +213 -0
  44. package/server/mode.js +182 -0
  45. package/server/paths.js +67 -0
  46. package/server/pdf.js +110 -0
  47. package/server/projects.js +152 -0
  48. package/server/sessions.js +341 -0
  49. package/server/watcher.js +200 -0
  50. package/web/base.css +134 -0
  51. package/web/board.css +561 -0
  52. package/web/board.js +777 -0
  53. package/web/favicon.js +41 -0
  54. package/web/favicon.svg +8 -0
  55. package/web/files.css +535 -0
  56. package/web/files.html +63 -0
  57. package/web/files.js +877 -0
  58. package/web/fonts/caveat-latin-ext.woff2 +0 -0
  59. package/web/fonts/caveat-latin.woff2 +0 -0
  60. package/web/fuzzy.js +57 -0
  61. package/web/index.html +112 -0
  62. package/web/md.js +238 -0
  63. package/web/mentions.js +188 -0
  64. package/web/refs.js +176 -0
  65. package/web/title.js +26 -0
  66. package/web/units.js +33 -0
  67. package/web/view.js +248 -0
package/web/refs.js ADDED
@@ -0,0 +1,176 @@
1
+ // ── The references inside a document ──────────────────────────────────────────
2
+ // Documents point at each other constantly — a table listing eight files, "the answers end up
3
+ // in observations/", "it produces out/report.pdf and out/report.html" — and until now that was
4
+ // all dead text: to open any of it you had to go back to the listing and hunt by hand.
5
+ //
6
+ // This decides, given the document you are reading and a piece of text, whether that piece is
7
+ // a reference to something real. There is one rule: **it has to exist**. There is no list of
8
+ // words to avoid, and none is needed: in a README full of `TRUE`, `spacing`, `active: false`
9
+ // and `bold`, existence throws all of them out by itself.
10
+ //
11
+ // The hard part is not finding the names, it is choosing **which** file. In a repository with
12
+ // nine `README.md` files, the right one is the one in the directory you are reading in — and
13
+ // failing that, the one in the directory above. Somebody writing `README.md` inside
14
+ // `interviews/` means the one in `interviews/`, not one picked at random.
15
+
16
+ /** The extensions the viewer can show. Same list as `DOC_EXT` on the server. */
17
+ const DOC = new Set([
18
+ '.md', '.markdown', '.mdx', '.html', '.htm', '.txt', '.rtf',
19
+ '.pdf', '.docx', '.doc', '.odt', '.pages',
20
+ ])
21
+
22
+ /**
23
+ * A piece of text that might be a name or a path. It is a string rather than a ready-made
24
+ * regular expression because the code that walks the real document needs it too, and a global
25
+ * regex carries around the position it last reached.
26
+ */
27
+ export const TOKEN_SRC = '[A-Za-z0-9.][A-Za-z0-9._/~-]*'
28
+ const tokenRe = () => new RegExp(TOKEN_SRC, 'g')
29
+
30
+ /** Punctuation stuck on the end is not part of the name: "…it is in from-the-call.md." */
31
+ export const trim = (t) => t.replace(/[.\-~]+$/, '')
32
+
33
+ const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
34
+ const dirOf = (p) => p.slice(0, Math.max(0, p.lastIndexOf('/')))
35
+ const extOf = (p) => {
36
+ const n = nameOf(p)
37
+ const i = n.lastIndexOf('.')
38
+ return i > 0 ? n.slice(i).toLowerCase() : ''
39
+ }
40
+
41
+ /**
42
+ * The path a reference written inside a document points at: `../x/y.md` read from `a/b/c.md`
43
+ * makes `a/x/y.md`. It lives here and not in the viewer because it is the same operation
44
+ * markdown links and names found in the text both need.
45
+ */
46
+ export function resolveRel(from, target) {
47
+ const clean = target.split('#')[0].split('?')[0]
48
+ if (!clean) return null
49
+ const parts = (clean.startsWith('/') ? [] : dirOf(from).split('/')).concat(clean.split('/'))
50
+ const out = []
51
+ for (const p of parts) {
52
+ if (!p || p === '.') continue
53
+ if (p === '..') out.pop()
54
+ else out.push(p)
55
+ }
56
+ return out.join('/')
57
+ }
58
+
59
+ /**
60
+ * A path that goes through a hidden directory. They stay out: `.claude/` and friends are
61
+ * configuration, not things to read, and the listing has never shown them.
62
+ */
63
+ const hidden = (p) => p.split('/').some((s) => s.startsWith('.') && s !== '.' && s !== '..')
64
+
65
+ /**
66
+ * The indexes the search runs on. `dirs` holds every directory with at least one file in the
67
+ * listing: a directory the viewer cannot list is not a place to send you.
68
+ */
69
+ export function index(files) {
70
+ const byPath = new Map()
71
+ const byName = new Map()
72
+ // Lower case → as it is written on disk. The comparison has to ignore case — somebody
73
+ // writing "Onboarding" in a sentence means the `onboarding` directory — but what comes back
74
+ // has to be the real path, otherwise the directory opens empty.
75
+ const dirs = new Map()
76
+ for (const f of files || []) {
77
+ const p = f.p
78
+ byPath.set(p.toLowerCase(), p)
79
+ const n = nameOf(p).toLowerCase()
80
+ const a = byName.get(n)
81
+ if (a) a.push(p)
82
+ else byName.set(n, [p])
83
+ const parts = p.split('/')
84
+ parts.pop()
85
+ for (let i = 1; i <= parts.length; i++) {
86
+ const d = parts.slice(0, i).join('/')
87
+ if (!dirs.has(d.toLowerCase())) dirs.set(d.toLowerCase(), d)
88
+ }
89
+ }
90
+ return { byPath, byName, dirs }
91
+ }
92
+
93
+ /**
94
+ * Is this piece of text worth trying to resolve?
95
+ *
96
+ * In plain prose the signal is the extension or the slash: "README" written in a sentence is a
97
+ * word, `README.md` is a file. Inside backticks the rule is looser — somebody who reached for
98
+ * backticks was already pointing at something — and a single word gets through too, which may
99
+ * turn out to be a directory.
100
+ */
101
+ export function worth(t, fromCode = false) {
102
+ if (!t || /\s/.test(t)) return false
103
+ const ext = extOf(t)
104
+ if (ext) return DOC.has(ext) // `.mjs`, `.js`: code, not something the viewer shows
105
+ return fromCode || t.includes('/')
106
+ }
107
+
108
+ /** Every piece of text in a document that would be worth trying to resolve. */
109
+ export function candidates(text) {
110
+ // Fenced blocks never become links — the renderer does not touch them — so there is no
111
+ // point even asking what is inside them.
112
+ const clean = String(text ?? '').replace(/^\s*(```+|~~~+)[\s\S]*?^\s*\1\s*$/gm, '')
113
+ const out = new Set()
114
+ for (const m of clean.matchAll(tokenRe())) {
115
+ const t = trim(m[0])
116
+ if (worth(t, true)) out.add(t)
117
+ }
118
+ return [...out]
119
+ }
120
+
121
+ /**
122
+ * What `t` points at, read from inside `doc`. Returns:
123
+ *
124
+ * - `{ path }` — a file the viewer already has in its listing;
125
+ * - `{ dir }` — a directory the viewer can list;
126
+ * - `{ ask }` — not in the listing, but it might exist anyway: these are the paths to have the
127
+ * server check. It is the case of the PDFs inside `out/`, which the listing skips but which a
128
+ * document names on purpose;
129
+ * - `null` — it is not a reference, or it is ambiguous and there is no way to choose.
130
+ */
131
+ export function resolve(t, doc, ix, fromCode = false) {
132
+ if (!worth(t, fromCode)) return null
133
+
134
+ const folder = !extOf(t)
135
+ const rooted = t.includes('/') || t.startsWith('.')
136
+
137
+ // With the path written out there is nothing to guess: it is read relative to the document.
138
+ // Without one, we climb — the directory you are reading in, then the one above, to the top.
139
+ const tries = []
140
+ if (rooted) {
141
+ const abs = resolveRel(doc, t)
142
+ if (abs) tries.push(abs)
143
+ // And then from the repository root: `interviews/` written inside `interviews/README.md`
144
+ // means that directory, not `interviews/interviews`. Somebody writing a path with slashes
145
+ // in it usually writes the whole thing, not one relative to where they are.
146
+ const bare = t.replace(/^(\.\.?\/)+/, '').replace(/\/$/, '')
147
+ if (bare && !tries.includes(bare)) tries.push(bare)
148
+ } else {
149
+ for (let d = dirOf(doc); ; d = dirOf(d)) {
150
+ tries.push(d ? `${d}/${t}` : t)
151
+ if (!d) break
152
+ }
153
+ }
154
+
155
+ for (const p of tries) {
156
+ if (hidden(p)) continue
157
+ const real = ix.byPath.get(p.toLowerCase())
158
+ if (real) return { path: real }
159
+ const dir = folder && ix.dirs.get(p.toLowerCase().replace(/\/$/, ''))
160
+ if (dir) return { dir }
161
+ }
162
+
163
+ // Not in the listing. If the bare name exists in exactly one place, that is the one; if
164
+ // there are several and climbing found nothing either, no link is better than sending you to
165
+ // the wrong README.
166
+ if (!rooted && !folder) {
167
+ const same = (ix.byName.get(t.toLowerCase()) || []).filter((p) => !hidden(p))
168
+ if (same.length === 1) return { path: same[0] }
169
+ if (same.length > 1) return null
170
+ }
171
+
172
+ // That leaves the server's road: a directory the listing skips is not a file that does not
173
+ // exist. The PDFs inside `out/` are there, and a document that names them knows it.
174
+ const ask = tries.filter((p) => !hidden(p) && DOC.has(extOf(p)))
175
+ return ask.length ? { ask } : null
176
+ }
package/web/title.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The title rule, one for everybody: the browser uses it as you type and the server uses it
3
+ * when it names the session. What you see in the field is exactly the name the session gets.
4
+ *
5
+ * Every word takes a capital on its first letter and keeps the rest EXACTLY as you wrote it —
6
+ * so "Fix API" stays "Fix-API", while "fix api" becomes "Fix-Api". Spaces become dashes.
7
+ *
8
+ * claude code -> Claude-Code
9
+ * Fix-API -> Fix-API
10
+ * fix api -> Fix-Api
11
+ * this and that -> This-And-That
12
+ * Export CSV -> Export-CSV
13
+ */
14
+ export const MAX = 60
15
+
16
+ export function titleCase(raw) {
17
+ return (raw || '')
18
+ .normalize('NFD')
19
+ .replace(/[\u0300-\u036f]/g, '') // accents go: this name ends up in a terminal
20
+ .replace(/[^a-zA-Z0-9]+/g, '-')
21
+ .replace(/^-+/, '') // a trailing dash stays: it is what lets you type the word after a space
22
+ .split('-')
23
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
24
+ .join('-')
25
+ .slice(0, MAX)
26
+ }
package/web/units.js ADDED
@@ -0,0 +1,33 @@
1
+ // ── Numbers as people read them ───────────────────────────────────────────────
2
+ // The measurements are written in one place, like the title rule in title.js: both pages use
3
+ // them, and one of them does so at a point where the shape of the number decides when to
4
+ // redraw.
5
+
6
+ const MB = 1048576
7
+ const GB = 1073741824
8
+
9
+ /**
10
+ * What a session weighs, rounded in steps: tens of MB below a gigabyte, tenths of a GB above.
11
+ *
12
+ * The rounding is not decoration. It is the rounded number — the one you read on the card —
13
+ * that goes into the signature of the refresh round: that way the board only redraws when the
14
+ * figure on screen really changes, instead of on every sample, throwing away the pointer and
15
+ * the buttons that appear under it.
16
+ */
17
+ export function weight(bytes) {
18
+ const mb = bytes / MB
19
+ if (mb >= 1000) return `${(Math.round(mb / 100) / 10).toFixed(1)} GB`
20
+ const step = Math.round(mb / 10) * 10
21
+ return `${step || Math.round(mb)} MB`
22
+ }
23
+
24
+ /** A file's exact size: there is nothing to round in steps here. */
25
+ export function bytes(n) {
26
+ if (n < 1024) return `${n} B`
27
+ if (n < MB) return `${Math.round(n / 1024)} KB`
28
+ if (n < GB) return `${(n / MB).toFixed(1)} MB`
29
+ return `${(n / GB).toFixed(1)} GB`
30
+ }
31
+
32
+ /** Round gigabytes, for the machine's own numbers. */
33
+ export const gb = (n) => `${(n / GB).toFixed(1)} GB`
package/web/view.js ADDED
@@ -0,0 +1,248 @@
1
+ // ── The view ──────────────────────────────────────────────────────────
2
+ // The board does not scroll: it moves. `#board` is the whole content, pushed underneath the
3
+ // fixed window of `#viewport` by a single transform. No bars and nothing to drag: you bring the
4
+ // pointer near an edge and the board slides that way — the closer you are, the faster it goes —
5
+ // and it stops dead as soon as there is nothing left on that side.
6
+ //
7
+
8
+ const $ = (s) => document.querySelector(s)
9
+
10
+ const EDGE = 96 // the sensitive band along each edge, in px
11
+ const SPEED = 1200 // px per second right at the edge; inside the band it accelerates
12
+ const STEPS = [0.3, 0.4, 0.5, 0.65, 0.8, 1, 1.25, 1.5, 2]
13
+
14
+ let vp, board, edges, level
15
+
16
+ // x and y are how far the board has already slid away, in screen px.
17
+ let view = { x: 0, y: 0, z: 1 }
18
+
19
+ // Measurements kept aside: reading them on every frame would cost a layout recalculation exactly
20
+ // while we are writing the transform. They are retaken only when the content or the window really
21
+ // change, which is to say in refit().
22
+ let box = { left: 0, top: 0, right: 0, bottom: 0, w: 0, h: 0 }
23
+ let contentW = 0
24
+ let contentH = 0
25
+
26
+ let px = 0
27
+ let py = 0
28
+ let inside = false // the pointer is inside the view (and not over the zoom controls)
29
+ let held = false // a button is down: the board stands still, the click has to land
30
+ let frame = 0 // the requestAnimationFrame handle, 0 = stopped
31
+ let last = 0
32
+
33
+ // ── Limits and drawing ────────────────────────────────────────────────
34
+ const maxX = () => contentW * view.z - box.w
35
+ const maxY = () => contentH * view.z - box.h
36
+
37
+ /**
38
+ * Keeps the view inside the board, and decides what to do with the space left over when the
39
+ * board is smaller than the window.
40
+ *
41
+ * The two axes are not treated the same, and that is the point. Sideways, a board narrower than
42
+ * the window is centred: with nothing anchoring the columns left or right, centred reads as
43
+ * deliberate. Downwards it is pinned to the top instead, because the columns hang from the top —
44
+ * they are different heights and they all start at the same line. Centring that vertically
45
+ * splits the leftover space above and below, and the gap above pushes every column away from the
46
+ * bar: it looks like a margin somebody forgot to remove, on exactly the screens where the board
47
+ * happens to fit.
48
+ */
49
+ function clamp() {
50
+ const mx = maxX()
51
+ const my = maxY()
52
+ view.x = mx > 0 ? Math.min(Math.max(view.x, 0), mx) : mx / 2
53
+ view.y = my > 0 ? Math.min(Math.max(view.y, 0), my) : 0
54
+ }
55
+
56
+ function apply() {
57
+ board.style.transform = `translate(${-view.x}px, ${-view.y}px) scale(${view.z})`
58
+ const mx = maxX()
59
+ const my = maxY()
60
+ edges.classList.toggle('can-l', view.x > 1)
61
+ edges.classList.toggle('can-r', view.x < mx - 1)
62
+ edges.classList.toggle('can-t', view.y > 1)
63
+ edges.classList.toggle('can-b', view.y < my - 1)
64
+ level.textContent = `${Math.round(view.z * 100)}%`
65
+ save()
66
+ }
67
+
68
+ function measure() {
69
+ const r = vp.getBoundingClientRect()
70
+ box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom, w: r.width, h: r.height }
71
+ // offsetWidth/offsetHeight are the layout measurements: the transform does not touch them.
72
+ contentW = board.offsetWidth
73
+ contentH = board.offsetHeight
74
+ }
75
+
76
+ // Writing on every frame would be sixty writes a second for nothing: where you are is noted
77
+ // when you stop.
78
+ let saveTimer
79
+ function save() {
80
+ clearTimeout(saveTimer)
81
+ saveTimer = setTimeout(() => localStorage.setItem('k0-view', JSON.stringify(view)), 400)
82
+ }
83
+
84
+ function load() {
85
+ try {
86
+ const v = JSON.parse(localStorage.getItem('k0-view') || '{}')
87
+ for (const k of ['x', 'y', 'z']) if (Number.isFinite(v[k])) view[k] = v[k]
88
+ view.z = Math.min(STEPS[STEPS.length - 1], Math.max(STEPS[0], view.z))
89
+ } catch {
90
+ /* an old view that cannot be read: start again from the top left corner */
91
+ }
92
+ }
93
+
94
+ // ── Edge scrolling ────────────────────────────────────────────────────
95
+ /**
96
+ * How much and which way to move, right now. The ramp is quadratic: entering the band it starts
97
+ * slowly, right at the edge it goes at full speed. Diagonals and corners come out by themselves,
98
+ * because there neither component is zero.
99
+ */
100
+ function velocity() {
101
+ if (held || !inside || document.querySelector('dialog[open]')) return [0, 0]
102
+ const ramp = (d) => {
103
+ const t = 1 - d / EDGE
104
+ return t > 0 ? t * t : 0
105
+ }
106
+ return [
107
+ (ramp(box.right - px) - ramp(px - box.left)) * SPEED,
108
+ (ramp(box.bottom - py) - ramp(py - box.top)) * SPEED,
109
+ ]
110
+ }
111
+
112
+ function step(t) {
113
+ const [vx, vy] = velocity()
114
+ if (!vx && !vy) return (frame = 0)
115
+ if (!last) {
116
+ // First frame: how much time has passed is not known yet, so it only looks.
117
+ last = t
118
+ frame = requestAnimationFrame(step)
119
+ return
120
+ }
121
+ const dt = Math.min((t - last) / 1000, 0.05) // a frozen frame must not become a jump
122
+ last = t
123
+ if (dt > 0) {
124
+ const x0 = view.x
125
+ const y0 = view.y
126
+ view.x += vx * dt
127
+ view.y += vy * dt
128
+ clamp()
129
+ if (view.x === x0 && view.y === y0) return (frame = 0) // a wall: this side is finished
130
+ apply()
131
+ }
132
+ frame = requestAnimationFrame(step)
133
+ }
134
+
135
+ /** The loop only runs while it is needed: with the board still, nothing is in motion. */
136
+ function wake() {
137
+ if (frame) return
138
+ last = 0
139
+ frame = requestAnimationFrame(step)
140
+ }
141
+
142
+ // ── Zoom ──────────────────────────────────────────────────────────────
143
+ /** Changes scale while holding still the point of the board under (sx, sy). */
144
+ function zoomTo(z, sx, sy) {
145
+ z = Math.min(STEPS[STEPS.length - 1], Math.max(STEPS[0], z))
146
+ const cx = (view.x + sx) / view.z
147
+ const cy = (view.y + sy) / view.z
148
+ view.z = z
149
+ view.x = cx * z - sx
150
+ view.y = cy * z - sy
151
+ clamp()
152
+ apply()
153
+ }
154
+
155
+ /** With + and − it jumps one step, holding the centre of the view still. */
156
+ function nudge(dir) {
157
+ const i = STEPS.findIndex((s) => Math.abs(s - view.z) < 1e-6)
158
+ const next =
159
+ i >= 0
160
+ ? STEPS[Math.min(STEPS.length - 1, Math.max(0, i + dir))]
161
+ : dir > 0
162
+ ? (STEPS.find((s) => s > view.z) ?? STEPS[STEPS.length - 1])
163
+ : ([...STEPS].reverse().find((s) => s < view.z) ?? STEPS[0])
164
+ zoomTo(next, box.w / 2, box.h / 2)
165
+ }
166
+
167
+ /** Shrinks just enough to fit the whole board in. */
168
+ function fit() {
169
+ if (!contentW || !contentH) return
170
+ zoomTo(Math.min(1, box.w / contentW, box.h / contentH), box.w / 2, box.h / 2)
171
+ }
172
+
173
+ // ── Wiring ────────────────────────────────────────────────────────────
174
+ /** The content changed: remeasure, come back inside the limits, and redraw. */
175
+ export function refit() {
176
+ if (!vp) return
177
+ measure()
178
+ clamp()
179
+ apply()
180
+ wake() // the board has grown: there may now be somewhere to go on this side
181
+ }
182
+
183
+ export function initView() {
184
+ vp = $('#viewport')
185
+ board = $('#board')
186
+ edges = $('#edges')
187
+ level = $('#z-level')
188
+
189
+ load()
190
+ measure()
191
+ clamp()
192
+ apply()
193
+
194
+ document.addEventListener('pointermove', (e) => {
195
+ px = e.clientX
196
+ py = e.clientY
197
+ inside =
198
+ px >= box.left &&
199
+ px <= box.right &&
200
+ py >= box.top &&
201
+ py <= box.bottom &&
202
+ !(e.target instanceof Element && e.target.closest('#zoom'))
203
+ wake()
204
+ })
205
+ // Outside the page, or with a button held, the board stands still: that way a click near the
206
+ // edge lands instead of chasing a card that is running away. A pointer that has left the window
207
+ // stops reporting, and without this the last report would stand — "I am at the edge" — and the
208
+ // board would scroll forever on its own.
209
+ document.documentElement.addEventListener('pointerleave', () => (inside = false))
210
+ document.addEventListener('pointerdown', () => (held = true))
211
+ document.addEventListener('pointerup', () => {
212
+ held = false
213
+ wake()
214
+ })
215
+ document.addEventListener('pointercancel', () => (held = false))
216
+ addEventListener('blur', () => {
217
+ inside = false
218
+ held = false
219
+ })
220
+
221
+ // Two fingers on the trackpad move the view; a pinch (which arrives as a wheel event with ctrl
222
+ // held) zooms under the pointer.
223
+ vp.addEventListener(
224
+ 'wheel',
225
+ (e) => {
226
+ e.preventDefault()
227
+ const k = e.deltaMode === 1 ? 16 : 1 // some mice count lines, not pixels
228
+ if (e.ctrlKey) {
229
+ zoomTo(view.z * Math.exp((-e.deltaY * k) / 240), e.clientX - box.left, e.clientY - box.top)
230
+ } else {
231
+ view.x += e.deltaX * k
232
+ view.y += e.deltaY * k
233
+ clamp()
234
+ apply()
235
+ }
236
+ },
237
+ { passive: false }
238
+ )
239
+
240
+ $('#z-out').onclick = () => nudge(-1)
241
+ $('#z-in').onclick = () => nudge(1)
242
+ $('#z-level').onclick = () => zoomTo(1, box.w / 2, box.h / 2)
243
+ $('#z-fit').onclick = () => fit()
244
+
245
+ addEventListener('resize', refit)
246
+ // Caveat is a web font: once it has loaded, the columns change size.
247
+ document.fonts?.ready.then(refit)
248
+ }