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/.claude/skills/k0-changelog/SKILL.md +96 -0
- package/CHANGELOG.md +185 -1
- package/README.md +289 -20
- package/bin/k0-board.js +51 -3
- package/package.json +6 -3
- package/platform/contract.js +30 -0
- package/platform/darwin/index.js +18 -3
- package/platform/darwin/servers.js +16 -0
- package/platform/index.js +9 -1
- package/platform/linux/index.js +18 -1
- package/platform/linux/servers.js +41 -0
- package/platform/shared/listeners.js +121 -0
- package/platform/shared/servers.js +111 -0
- package/platform/win32/index.js +15 -1
- package/platform/win32/servers.js +73 -0
- package/server/changelog.js +242 -0
- package/server/db.js +69 -0
- package/server/git.js +135 -0
- package/server/guard.js +31 -0
- package/server/idle.js +56 -0
- package/server/index.js +177 -36
- package/server/launcher.js +5 -1
- package/server/machine.js +9 -0
- package/server/paths.js +5 -4
- package/server/servers.js +481 -0
- package/server/settings.js +121 -0
- package/server/watcher.js +8 -1
- package/server/writer.js +128 -0
- package/web/board.css +137 -7
- package/web/board.js +285 -10
- package/web/changelog.css +185 -0
- package/web/changelog.html +43 -0
- package/web/changelog.js +298 -0
- package/web/index.html +8 -2
- package/web/recency.js +86 -0
- package/web/view.js +3 -1
package/web/changelog.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { render } from '/md.js'
|
|
2
|
+
|
|
3
|
+
// ── The ChangeLog ────────────────────────────────────────────────────────────
|
|
4
|
+
// k0's third page. The board says what is happening; this says what happened.
|
|
5
|
+
//
|
|
6
|
+
// It is built in two passes on purpose. The facts come back from the server straight away and
|
|
7
|
+
// are on screen before you have finished reading the heading; the words take a model half a
|
|
8
|
+
// minute, and arrive into a page that was already worth looking at. A summary that shows a
|
|
9
|
+
// spinner over an empty sheet is a summary you close.
|
|
10
|
+
|
|
11
|
+
const $ = (s) => document.querySelector(s)
|
|
12
|
+
|
|
13
|
+
const api = async (url, opts) => {
|
|
14
|
+
const r = await fetch(url, { headers: { 'content-type': 'application/json' }, ...opts })
|
|
15
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText)
|
|
16
|
+
return r.json()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
// Something to read while the model reads. They are not jokes exactly — they say what is
|
|
22
|
+
// actually going on, which is the only reason a waiting message is ever worth having.
|
|
23
|
+
const PHRASES = [
|
|
24
|
+
'Reading the commits…',
|
|
25
|
+
'Working out what actually shipped…',
|
|
26
|
+
'Translating git into human…',
|
|
27
|
+
'Looking for what you left half-done…',
|
|
28
|
+
'Checking what never made it out of here…',
|
|
29
|
+
'Putting the day in order…',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
let period = 'last'
|
|
33
|
+
let facts = null
|
|
34
|
+
let poll = null
|
|
35
|
+
let phrases = null
|
|
36
|
+
const put = new Set() // repositories folded away by hand, this visit only
|
|
37
|
+
|
|
38
|
+
// ── Painting ─────────────────────────────────────────────────────────────────
|
|
39
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
40
|
+
|
|
41
|
+
const day = (t) => new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'long' })
|
|
42
|
+
|
|
43
|
+
/** Which stretch of time is on screen, said the way you would say it. */
|
|
44
|
+
function whenLabel(data) {
|
|
45
|
+
const single = data.to - data.from <= DAY
|
|
46
|
+
if (!single) return `${day(data.from)} – ${day(data.to - 1)}`
|
|
47
|
+
const today = new Date()
|
|
48
|
+
today.setHours(0, 0, 0, 0)
|
|
49
|
+
if (data.from === today.getTime()) return 'Today'
|
|
50
|
+
if (data.from === today.getTime() - DAY) return 'Yesterday'
|
|
51
|
+
return day(data.from)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The written summary comes back as one piece of Markdown with a `## name` per repository.
|
|
56
|
+
* It is cut back apart here so each repository's words can sit with its own facts — and so
|
|
57
|
+
* that folding a repository away takes its paragraph with it.
|
|
58
|
+
*
|
|
59
|
+
* The headings themselves are dropped: the block already carries the name, and this way a
|
|
60
|
+
* repository is always called what k0 calls it. Anything that matches no repository is kept
|
|
61
|
+
* and shown at the top rather than quietly lost.
|
|
62
|
+
*/
|
|
63
|
+
function sectionise(html) {
|
|
64
|
+
const holder = document.createElement('div')
|
|
65
|
+
holder.innerHTML = html
|
|
66
|
+
const loose = []
|
|
67
|
+
const map = new Map()
|
|
68
|
+
let current = null
|
|
69
|
+
for (const node of [...holder.childNodes]) {
|
|
70
|
+
if (node.nodeType === 1 && node.tagName === 'H2') {
|
|
71
|
+
current = node.textContent.trim().toLowerCase()
|
|
72
|
+
map.set(current, [])
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (current) map.get(current).push(node)
|
|
76
|
+
else loose.push(node)
|
|
77
|
+
}
|
|
78
|
+
return { loose, map }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function commitList(r) {
|
|
82
|
+
const ul = document.createElement('ul')
|
|
83
|
+
ul.className = 'commits'
|
|
84
|
+
for (const c of r.commits) {
|
|
85
|
+
const li = document.createElement('li')
|
|
86
|
+
const dot = document.createElement('i')
|
|
87
|
+
dot.className = c.online === false ? 'local' : 'online'
|
|
88
|
+
dot.title = c.online === false ? 'Still on this machine' : 'Online'
|
|
89
|
+
li.append(dot, document.createTextNode(c.subject))
|
|
90
|
+
ul.append(li)
|
|
91
|
+
}
|
|
92
|
+
return ul
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What is still outstanding here — and only for repositories you actually touched. */
|
|
96
|
+
function pending(r) {
|
|
97
|
+
const lines = []
|
|
98
|
+
if (r.unpushedTotal) lines.push(`${plural(r.unpushedTotal, 'commit')} still on this machine`)
|
|
99
|
+
if (r.dirtyTotal) lines.push(`${plural(r.dirtyTotal, 'file')} changed and never committed`)
|
|
100
|
+
if (r.unreleased) lines.push(`${plural(r.unreleased.filter((l) => l.startsWith('-')).length, 'changelog line')} not yet released`)
|
|
101
|
+
const open = r.cards.filter((c) => !c.done)
|
|
102
|
+
if (open.length) lines.push(`${plural(open.length, 'card')} still open`)
|
|
103
|
+
if (!lines.length) return null
|
|
104
|
+
const p = document.createElement('p')
|
|
105
|
+
p.className = 'pending'
|
|
106
|
+
p.textContent = lines.join(' · ')
|
|
107
|
+
return p
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function factsBlock(r) {
|
|
111
|
+
const d = document.createElement('details')
|
|
112
|
+
d.className = 'facts'
|
|
113
|
+
const s = document.createElement('summary')
|
|
114
|
+
const local = r.commits.filter((c) => c.online === false).length
|
|
115
|
+
const bits = []
|
|
116
|
+
if (r.commits.length) bits.push(plural(r.commits.length, 'commit'))
|
|
117
|
+
if (local) bits.push(`${local} not out yet`)
|
|
118
|
+
if (r.dirty.length) bits.push(plural(r.dirty.length, 'file touched', 'files touched'))
|
|
119
|
+
if (r.cards.length) bits.push(plural(r.cards.length, 'card'))
|
|
120
|
+
s.textContent = bits.join(' · ') || 'nothing recorded'
|
|
121
|
+
d.append(s)
|
|
122
|
+
if (r.commits.length) d.append(commitList(r))
|
|
123
|
+
const p = pending(r)
|
|
124
|
+
if (p) d.append(p)
|
|
125
|
+
return d
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function repoBlock(r, nodes) {
|
|
129
|
+
const sec = document.createElement('section')
|
|
130
|
+
sec.className = 'repo'
|
|
131
|
+
const h = document.createElement('h2')
|
|
132
|
+
h.textContent = r.name
|
|
133
|
+
sec.append(h)
|
|
134
|
+
if (nodes?.length) {
|
|
135
|
+
const prose = document.createElement('div')
|
|
136
|
+
prose.className = 'prose'
|
|
137
|
+
prose.append(...nodes)
|
|
138
|
+
sec.append(prose)
|
|
139
|
+
}
|
|
140
|
+
sec.append(factsBlock(r))
|
|
141
|
+
return sec
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function paintChips(data) {
|
|
145
|
+
const box = $('#chips')
|
|
146
|
+
box.textContent = ''
|
|
147
|
+
box.hidden = data.repos.length < 2
|
|
148
|
+
for (const r of data.repos) {
|
|
149
|
+
const b = document.createElement('button')
|
|
150
|
+
b.type = 'button'
|
|
151
|
+
b.textContent = r.name
|
|
152
|
+
b.setAttribute('aria-pressed', String(!put.has(r.name)))
|
|
153
|
+
b.onclick = () => {
|
|
154
|
+
if (put.has(r.name)) put.delete(r.name)
|
|
155
|
+
else put.add(r.name)
|
|
156
|
+
paint()
|
|
157
|
+
}
|
|
158
|
+
box.append(b)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Draws whatever is known right now: the facts always, the words if they have arrived. */
|
|
163
|
+
function paint(text = null) {
|
|
164
|
+
const data = facts
|
|
165
|
+
if (!data) return
|
|
166
|
+
const { loose, map } = text ? sectionise(render(text)) : { loose: [], map: new Map() }
|
|
167
|
+
|
|
168
|
+
const intro = $('#intro')
|
|
169
|
+
intro.textContent = ''
|
|
170
|
+
if (loose.length) intro.append(...loose)
|
|
171
|
+
|
|
172
|
+
const box = $('#repos')
|
|
173
|
+
box.textContent = ''
|
|
174
|
+
const mine = new Set(data.repos.map((r) => r.name.toLowerCase()))
|
|
175
|
+
for (const r of data.repos) {
|
|
176
|
+
if (put.has(r.name)) continue
|
|
177
|
+
box.append(repoBlock(r, map.get(r.name.toLowerCase())))
|
|
178
|
+
}
|
|
179
|
+
// A heading that matches no repository — a name written differently, a paragraph the model
|
|
180
|
+
// decided to group under something of its own. It goes at the top rather than nowhere: losing
|
|
181
|
+
// a piece of the summary without telling anybody is the one failure that would not show.
|
|
182
|
+
for (const [name, nodes] of map) if (!mine.has(name)) intro.append(...nodes)
|
|
183
|
+
paintChips(data)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function say(message, kind = 'note') {
|
|
187
|
+
const intro = $('#intro')
|
|
188
|
+
intro.textContent = ''
|
|
189
|
+
const p = document.createElement('p')
|
|
190
|
+
p.className = kind
|
|
191
|
+
p.textContent = message
|
|
192
|
+
intro.append(p)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Waiting ──────────────────────────────────────────────────────────────────
|
|
196
|
+
function waiting(on) {
|
|
197
|
+
$('#waiting').hidden = !on
|
|
198
|
+
clearInterval(phrases)
|
|
199
|
+
if (!on) return
|
|
200
|
+
let i = 0
|
|
201
|
+
$('#phrase').textContent = PHRASES[0]
|
|
202
|
+
phrases = setInterval(() => {
|
|
203
|
+
i = (i + 1) % PHRASES.length
|
|
204
|
+
$('#phrase').textContent = PHRASES[i]
|
|
205
|
+
}, 2600)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stop() {
|
|
209
|
+
clearInterval(poll)
|
|
210
|
+
poll = null
|
|
211
|
+
waiting(false)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function arrived(state) {
|
|
215
|
+
if (state.text) {
|
|
216
|
+
stop()
|
|
217
|
+
paint(state.text)
|
|
218
|
+
return true
|
|
219
|
+
}
|
|
220
|
+
if (state.error) {
|
|
221
|
+
stop()
|
|
222
|
+
say(state.error, 'note')
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function writeUp() {
|
|
229
|
+
waiting(true)
|
|
230
|
+
try {
|
|
231
|
+
if (arrived(await api(`/api/changelog/write?period=${period}`, { method: 'POST' }))) return
|
|
232
|
+
} catch (err) {
|
|
233
|
+
stop()
|
|
234
|
+
say(String(err.message || err), 'note')
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
poll = setInterval(async () => {
|
|
238
|
+
try {
|
|
239
|
+
arrived(await api(`/api/changelog/write?period=${period}`))
|
|
240
|
+
} catch {
|
|
241
|
+
/* the server is restarting: the next round will find it */
|
|
242
|
+
}
|
|
243
|
+
}, 1000)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Loading ──────────────────────────────────────────────────────────────────
|
|
247
|
+
/**
|
|
248
|
+
* Which of the four is lit. The page opens on none of them — it opens on the last day that had
|
|
249
|
+
* anything in it — but when that day turns out to be today or yesterday, lighting the button
|
|
250
|
+
* says so more clearly than the date does.
|
|
251
|
+
*/
|
|
252
|
+
function markPeriod(data) {
|
|
253
|
+
let lit = period
|
|
254
|
+
if (period === 'last' && data && data.to - data.from <= DAY) {
|
|
255
|
+
const today = new Date()
|
|
256
|
+
today.setHours(0, 0, 0, 0)
|
|
257
|
+
if (data.from === today.getTime()) lit = 'today'
|
|
258
|
+
else if (data.from === today.getTime() - DAY) lit = 'yesterday'
|
|
259
|
+
}
|
|
260
|
+
for (const b of document.querySelectorAll('#periods button')) {
|
|
261
|
+
b.setAttribute('aria-checked', String(b.dataset.period === lit))
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function load(p) {
|
|
266
|
+
period = p
|
|
267
|
+
stop()
|
|
268
|
+
markPeriod(null)
|
|
269
|
+
|
|
270
|
+
facts = await api(`/api/changelog?period=${p}`)
|
|
271
|
+
markPeriod(facts)
|
|
272
|
+
$('#when').textContent = whenLabel(facts)
|
|
273
|
+
$('#blank').hidden = true
|
|
274
|
+
paint()
|
|
275
|
+
|
|
276
|
+
if (!facts.repos.length) {
|
|
277
|
+
// Nothing happened. Say it in a line and stop there — no model, nothing to pad out.
|
|
278
|
+
$('#intro').textContent = ''
|
|
279
|
+
$('#chips').hidden = true
|
|
280
|
+
$('#blank').hidden = false
|
|
281
|
+
$('#blank').textContent =
|
|
282
|
+
p === 'last' ? 'Nothing in the last thirty days.' : `Nothing in ${whenLabel(facts).toLowerCase()}.`
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!facts.writer.can) {
|
|
287
|
+
say(`${facts.writer.why} The facts are all here.`)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
writeUp()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
$('#periods').onclick = (e) => {
|
|
294
|
+
const b = e.target.closest('button[data-period]')
|
|
295
|
+
if (b && b.dataset.period !== period) load(b.dataset.period)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
load('last')
|
package/web/index.html
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
<button class="new" id="new-card" title="New card" aria-label="New card">
|
|
14
14
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
15
15
|
</button>
|
|
16
|
+
<!-- Next to the plus, because they are the two ends of the same thing: one starts a piece
|
|
17
|
+
of work, the other tells you how the ones before it went. A plain link, and a quiet
|
|
18
|
+
one — no dot, no badge. It is there when you want it. -->
|
|
19
|
+
<a class="log" href="/changelog.html" title="What you have been doing" aria-label="What you have been doing">
|
|
20
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h11M4 17h7"/></svg>
|
|
21
|
+
</a>
|
|
16
22
|
<div class="filters" id="filters"></div>
|
|
17
23
|
|
|
18
24
|
<!-- How the computer is doing. Always visible; clicking it switches off the weights on the cards. -->
|
|
@@ -46,11 +52,11 @@
|
|
|
46
52
|
<div class="edges" id="edges" aria-hidden="true"><i class="l"></i><i class="r"></i><i class="t"></i><i class="b"></i></div>
|
|
47
53
|
|
|
48
54
|
<div class="zoom" id="zoom">
|
|
49
|
-
<button type="button" id="z-out" title="Zoom out" aria-label="Zoom out">
|
|
55
|
+
<button type="button" id="z-out" title="Zoom out, ten per cent at a time" aria-label="Zoom out">
|
|
50
56
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
|
51
57
|
</button>
|
|
52
58
|
<button type="button" id="z-level" title="Back to 100%">100%</button>
|
|
53
|
-
<button type="button" id="z-in" title="Zoom in" aria-label="Zoom in">
|
|
59
|
+
<button type="button" id="z-in" title="Zoom in, ten per cent at a time" aria-label="Zoom in">
|
|
54
60
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
55
61
|
</button>
|
|
56
62
|
<button type="button" id="z-fit" title="Fit the whole board on screen" aria-label="Fit to screen">
|
package/web/recency.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// ── Which repositories are still warm ──────────────────────────────
|
|
2
|
+
// With a dozen repositories open the board is wider than the screen, and the three or four you
|
|
3
|
+
// are actually on today sit scattered among all the others. This decides which columns stay
|
|
4
|
+
// open and which fold away into `Old`, and it is here rather than in board.js for the reason
|
|
5
|
+
// docs/testing.md gives: what is worth proving comes out of the browser files into one of its
|
|
6
|
+
// own. Nothing in here touches the DOM.
|
|
7
|
+
|
|
8
|
+
export const DAY = 24 * 60 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The statuses that hold a column on the board whatever its age: something is happening in there
|
|
12
|
+
* right now, or something is on screen waiting for you. Hiding the one that is waiting for you is
|
|
13
|
+
* the exact opposite of what the board is for.
|
|
14
|
+
*
|
|
15
|
+
* `IDLE` is deliberately not one of them, and it is the only debatable line in this file. It
|
|
16
|
+
* means "your turn", so it looks like it belongs — but it is also the resting state of every
|
|
17
|
+
* terminal you have left open, and on a real board nearly every live card is sitting in it. Let
|
|
18
|
+
* it hold a column and nothing ever folds: a session you finished with three weeks ago and never
|
|
19
|
+
* closed would keep its column at full width for ever. So `IDLE` gets the same treatment as
|
|
20
|
+
* everything else — recent, and it stays; a fortnight old, and it goes to `Old`, where it is one
|
|
21
|
+
* click away. Nothing is closed either way: the terminal is still open, and the card is still
|
|
22
|
+
* exactly where you left it.
|
|
23
|
+
*/
|
|
24
|
+
const DEMANDING = new Set(['ASK', 'PLANNED', 'WORKING', 'PLANNING'])
|
|
25
|
+
|
|
26
|
+
/** The last time each repository was touched: the freshest of its cards. */
|
|
27
|
+
export function lastTouched(cards) {
|
|
28
|
+
const map = new Map()
|
|
29
|
+
for (const c of cards) {
|
|
30
|
+
const t = Number(c.updated_at) || 0
|
|
31
|
+
if (t > (map.get(c.project_path) ?? 0)) map.set(c.project_path, t)
|
|
32
|
+
}
|
|
33
|
+
return map
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The repositories with something going on in them right now. */
|
|
37
|
+
export function busy(cards) {
|
|
38
|
+
const set = new Set()
|
|
39
|
+
for (const c of cards) if (DEMANDING.has(c.status)) set.add(c.project_path)
|
|
40
|
+
return set
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The line between recent and old. It is **not** "now minus a day": it is a day back from the
|
|
45
|
+
* most recent thing on the whole board.
|
|
46
|
+
*
|
|
47
|
+
* That one difference is what makes it survive a weekend. Anchored to the clock, a Monday
|
|
48
|
+
* morning would find every column older than a day and fold the lot — you would open k0 to an
|
|
49
|
+
* empty board on the one morning you most need to see where you left off. Anchored to the work,
|
|
50
|
+
* Monday shows you Friday, and the moment you start something the line moves with you.
|
|
51
|
+
*/
|
|
52
|
+
export function cutoff(touched) {
|
|
53
|
+
let newest = 0
|
|
54
|
+
for (const t of touched.values()) if (t > newest) newest = t
|
|
55
|
+
return newest ? newest - DAY : 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Which columns stay open, and which fold into `Old`. Four rules, in this order:
|
|
60
|
+
*
|
|
61
|
+
* 1. a repository with something going on in it is always open — see DEMANDING above, and note
|
|
62
|
+
* this beats a fold you did by hand: something in there woke up and wants you;
|
|
63
|
+
* 2. one you opened by hand during this visit stays open;
|
|
64
|
+
* 3. one you put away by hand stays away;
|
|
65
|
+
* 4. otherwise, open if it was touched after the cutoff.
|
|
66
|
+
*
|
|
67
|
+
* `held` carries the fifth rule, which lives in the caller: within one visit the set of open
|
|
68
|
+
* columns only ever grows. A column does not fold out from under you while you are working —
|
|
69
|
+
* it folds the next time the page loads. It is the same reason the columns only reorder
|
|
70
|
+
* themselves in driving mode.
|
|
71
|
+
*
|
|
72
|
+
* The order of `paths` is kept: whoever asked has already sorted them.
|
|
73
|
+
*/
|
|
74
|
+
export function split({ paths, cards, folded = new Set(), held = new Set() }) {
|
|
75
|
+
const touched = lastTouched(cards)
|
|
76
|
+
const line = cutoff(touched)
|
|
77
|
+
const alive = busy(cards)
|
|
78
|
+
const open = []
|
|
79
|
+
const old = []
|
|
80
|
+
for (const p of paths) {
|
|
81
|
+
const stays =
|
|
82
|
+
alive.has(p) || held.has(p) || (!folded.has(p) && (touched.get(p) ?? 0) >= line)
|
|
83
|
+
;(stays ? open : old).push(p)
|
|
84
|
+
}
|
|
85
|
+
return { open, old, touched, alive }
|
|
86
|
+
}
|
package/web/view.js
CHANGED
|
@@ -9,7 +9,9 @@ const $ = (s) => document.querySelector(s)
|
|
|
9
9
|
|
|
10
10
|
const EDGE = 96 // the sensitive band along each edge, in px
|
|
11
11
|
const SPEED = 1200 // px per second right at the edge; inside the band it accelerates
|
|
12
|
-
|
|
12
|
+
// The ladder is even on purpose: every click of + or − is ten points, so where the next one lands
|
|
13
|
+
// is something you can predict rather than discover.
|
|
14
|
+
const STEPS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2]
|
|
13
15
|
|
|
14
16
|
let vp, board, edges, level
|
|
15
17
|
|