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.
- 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 +426 -1
- package/LICENSE +83 -18
- package/README.md +1141 -119
- package/bin/k0-board.js +127 -12
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +14 -2
- 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/cloud.js +355 -0
- package/server/db.js +2117 -146
- package/server/files.js +60 -21
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1341 -186
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1069 -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/sync.js +383 -0
- package/server/update.js +325 -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/account.js +265 -0
- package/web/awake.js +62 -0
- package/web/base.css +97 -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 +110 -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/server/writer.js
CHANGED
|
@@ -15,7 +15,7 @@ import { ROOT } from './paths.js'
|
|
|
15
15
|
// it the facts and gets prose back, the same arrangement `/k0-import` has always had, except
|
|
16
16
|
// this one is invisible: no terminal opens and nothing lands on the board. `sessions.js`
|
|
17
17
|
// already drops `claude -p` runs when it looks for sessions to import, so these cannot come
|
|
18
|
-
// back later as
|
|
18
|
+
// back later as stories.
|
|
19
19
|
|
|
20
20
|
// `K0_CLAUDE` names the executable outright. It exists for two reasons: a test must never be
|
|
21
21
|
// able to reach the real Claude Code by accident, and an installation that keeps it somewhere
|
|
@@ -32,54 +32,125 @@ const CLAUDE = () => {
|
|
|
32
32
|
])
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const
|
|
35
|
+
const skillFile = (skill) => path.join(ROOT, '.claude', 'skills', skill, 'SKILL.md')
|
|
36
36
|
const TIMEOUT = 3 * 60 * 1000
|
|
37
37
|
const MAX_OUTPUT = 1 << 20
|
|
38
38
|
|
|
39
|
+
// A skill name goes onto a command line as `/<name>`, so it is not allowed to be anything the
|
|
40
|
+
// caller feels like. Nothing here takes one from a request today, and the guard is what keeps that
|
|
41
|
+
// true after somebody adds an endpoint that does.
|
|
42
|
+
const NAMED = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Whether the write-up can happen at all and, if not, why — in a sentence meant for the page
|
|
41
46
|
* rather than for a log. An adapter never pretends, and neither does this: the facts are shown
|
|
42
47
|
* either way, with the missing half explained instead of quietly absent.
|
|
43
48
|
*/
|
|
44
|
-
export function capability() {
|
|
49
|
+
export function capability(skill) {
|
|
50
|
+
if (!NAMED.test(String(skill ?? ''))) return { can: false, why: `${skill} is not the name of a skill.` }
|
|
45
51
|
if (!CLAUDE()) {
|
|
46
52
|
return { can: false, why: 'Claude Code is not on this machine, so there is nobody here to write the summary.' }
|
|
47
53
|
}
|
|
48
|
-
if (!fs.existsSync(
|
|
49
|
-
return { can: false, why:
|
|
54
|
+
if (!fs.existsSync(skillFile(skill))) {
|
|
55
|
+
return { can: false, why: `The ${skill} skill is missing from this copy of k0.` }
|
|
50
56
|
}
|
|
51
57
|
return { can: true, why: null }
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
// One
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
60
|
+
// One job per key, and the key is whatever the page that asked is waiting on: the ChangeLog's
|
|
61
|
+
// window, or What's New in one language at one level. Two pages can be writing at once — they
|
|
62
|
+
// were not able to before, and they have to be, because one of them opens on top of the other.
|
|
63
|
+
//
|
|
64
|
+
// Within a key it is still one at a time: a second window of the same page must not start a
|
|
65
|
+
// second model. Each run gets a number, and only the run that is still the current one for its
|
|
66
|
+
// key may record anything — otherwise a run abandoned halfway would come back later and
|
|
67
|
+
// overwrite the answer somebody is already reading.
|
|
68
|
+
const jobs = new Map()
|
|
59
69
|
let runs = 0
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// A finished job holds its whole page in memory, and a key is a window or a language: leave them
|
|
72
|
+
// all in and a tab left open for a week keeps every summary it ever asked for. A Map hands them
|
|
73
|
+
// back in the order they were made, so the oldest finished one goes first; anything still running
|
|
74
|
+
// stays, whatever else has to.
|
|
75
|
+
const MAX_JOBS = 8
|
|
76
|
+
|
|
77
|
+
/** Room for the job about to be made — one more than is there, unless this key is already held. */
|
|
78
|
+
function makeRoom(incoming) {
|
|
79
|
+
const ceiling = jobs.has(incoming) ? MAX_JOBS : MAX_JOBS - 1
|
|
80
|
+
for (const [key, job] of jobs) {
|
|
81
|
+
if (jobs.size <= ceiling) return
|
|
82
|
+
if (!job.running) jobs.delete(key)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
64
85
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
// Three at a time, and the machine they think on is the user's own — the one they are also trying
|
|
87
|
+
// to work on. Two pages open at once is the real case; three is already generous.
|
|
88
|
+
const MAX_RUNNING = 3
|
|
89
|
+
|
|
90
|
+
const running = () => {
|
|
91
|
+
let n = 0
|
|
92
|
+
for (const job of jobs.values()) if (job.running) n++
|
|
93
|
+
return n
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Room for one more model to think in: the oldest run still going gives up its place.
|
|
98
|
+
*
|
|
99
|
+
* Not a summary snatched away from somebody. Two pages write, and each of them watches one key at
|
|
100
|
+
* a time — switching the period on the ChangeLog stops reading the run it started and asks for
|
|
101
|
+
* another, in the same second. So a fourth run means three keys have already been walked away
|
|
102
|
+
* from, and the oldest of them is the one nobody is waiting for.
|
|
103
|
+
*
|
|
104
|
+
* Refusing instead is what the page cannot do anything with, and it is what this used to do: click
|
|
105
|
+
* through Today, Yesterday and Week and the fourth click printed "ask again when one of them is
|
|
106
|
+
* done" to somebody who had only pressed a button. The job is dropped before the child is killed,
|
|
107
|
+
* so its own `close` finds nothing under its key and writes no error over a key that has moved on.
|
|
108
|
+
*/
|
|
109
|
+
function retireOldest() {
|
|
110
|
+
for (const [key, job] of jobs) {
|
|
111
|
+
if (!job.running) continue
|
|
112
|
+
jobs.delete(key)
|
|
113
|
+
job.child?.kill('SIGTERM')
|
|
114
|
+
return true
|
|
69
115
|
}
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A run that never started, remembered under its key as though it had finished badly. The page
|
|
120
|
+
// asks once and then polls, so a refusal nobody wrote down would leave it waiting for ever.
|
|
121
|
+
const refuse = (key, why) => {
|
|
122
|
+
jobs.set(key, { id: ++runs, key, running: false, text: null, error: why, child: null })
|
|
123
|
+
return state(key)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Starts `claude -p /<skill>` for a key, unless that key is already running or already done.
|
|
128
|
+
* The payload goes in on standard input; what comes back is read with `state(key)`.
|
|
129
|
+
*
|
|
130
|
+
* A run belongs to its key and to nothing else: it never touches another key's job, so the
|
|
131
|
+
* ChangeLog cannot cancel What's New by being asked for a second time.
|
|
132
|
+
*/
|
|
133
|
+
export function run(skill, key, payload) {
|
|
134
|
+
const held = jobs.get(key)
|
|
135
|
+
if (held && (held.running || held.text)) return state(key)
|
|
136
|
+
makeRoom(key)
|
|
137
|
+
|
|
138
|
+
const can = capability(skill)
|
|
139
|
+
if (!can.can) return refuse(key, can.why)
|
|
140
|
+
while (running() >= MAX_RUNNING) if (!retireOldest()) break
|
|
70
141
|
|
|
71
|
-
if (job?.child) job.child.kill('SIGTERM')
|
|
72
142
|
const id = ++runs
|
|
73
|
-
const child = spawn(CLAUDE(), ['-p',
|
|
143
|
+
const child = spawn(CLAUDE(), ['-p', `/${skill}`, '--output-format', 'text'], {
|
|
74
144
|
cwd: ROOT, // where the project skill lives, whether k0 runs installed or from a checkout
|
|
75
145
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
76
146
|
})
|
|
77
|
-
|
|
147
|
+
jobs.set(key, { id, key, running: true, text: null, error: null, child })
|
|
78
148
|
|
|
79
149
|
/** Records the end of THIS run, once. A run that has already ended cannot end again. */
|
|
80
150
|
const finish = (patch) => {
|
|
81
|
-
|
|
82
|
-
|
|
151
|
+
const mine = jobs.get(key)
|
|
152
|
+
if (mine?.id !== id || !mine.running) return
|
|
153
|
+
jobs.set(key, { ...mine, ...patch, running: false, child: null })
|
|
83
154
|
}
|
|
84
155
|
|
|
85
156
|
let out = ''
|
|
@@ -115,14 +186,15 @@ export function write(key, payload) {
|
|
|
115
186
|
return state(key)
|
|
116
187
|
}
|
|
117
188
|
|
|
118
|
-
/** What the page is waiting for. A
|
|
189
|
+
/** What the page is waiting for. A key nobody has asked about is simply not running. */
|
|
119
190
|
export function state(key) {
|
|
120
|
-
|
|
191
|
+
const job = jobs.get(key)
|
|
192
|
+
if (!job) return { running: false, text: null, error: null }
|
|
121
193
|
return { running: !!job.running, text: job.text ?? null, error: job.error ?? null }
|
|
122
194
|
}
|
|
123
195
|
|
|
124
196
|
/** Called on the way out, so a model left mid-sentence does not outlive the server. */
|
|
125
197
|
export function stop() {
|
|
126
|
-
if (job
|
|
127
|
-
|
|
198
|
+
for (const job of jobs.values()) if (job.child) job.child.kill('SIGTERM')
|
|
199
|
+
jobs.clear()
|
|
128
200
|
}
|
package/web/account.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// ── The account, on the board ────────────────────────────────────────────────
|
|
2
|
+
// Two things, and only two. A chip at the end of the top bar saying who is signed in and whether
|
|
3
|
+
// the cloud has everything — a dot, green when it does, amber while something is waiting to go
|
|
4
|
+
// up, grey when k0 Cloud cannot be reached and the board is carrying on by itself. And, until an
|
|
5
|
+
// account is linked and let in, a screen in front of the board that says what to do about it.
|
|
6
|
+
//
|
|
7
|
+
// The screen only appears when there is a way past it: if the cloud has no way of signing in
|
|
8
|
+
// switched on yet, there is nothing the person in front of it could do, and blocking their board
|
|
9
|
+
// for that would be k0's failure dressed up as theirs. Offline at the first start is the same: the
|
|
10
|
+
// screen says so rather than offering buttons that cannot work.
|
|
11
|
+
|
|
12
|
+
const $ = (sel, root = document) => root.querySelector(sel)
|
|
13
|
+
|
|
14
|
+
const NAMES = { github: 'GitHub', google: 'Google' }
|
|
15
|
+
|
|
16
|
+
let last = null
|
|
17
|
+
|
|
18
|
+
async function get() {
|
|
19
|
+
const res = await fetch('/api/cloud', { cache: 'no-store' })
|
|
20
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
21
|
+
return res.json()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function post(action, body = {}) {
|
|
25
|
+
const res = await fetch(`/api/cloud/${action}`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
})
|
|
30
|
+
const data = await res.json().catch(() => ({}))
|
|
31
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
|
32
|
+
return data
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function el(tag, attrs = {}, ...children) {
|
|
36
|
+
const node = document.createElement(tag)
|
|
37
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
38
|
+
if (k === 'class') node.className = v
|
|
39
|
+
else if (k.startsWith('on')) node.addEventListener(k.slice(2), v)
|
|
40
|
+
else if (v != null) node.setAttribute(k, v)
|
|
41
|
+
}
|
|
42
|
+
for (const c of children) if (c != null) node.append(c)
|
|
43
|
+
return node
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── The screen in front of the board ─────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
let later = false
|
|
49
|
+
|
|
50
|
+
function gate(s) {
|
|
51
|
+
let box = $('#account-gate')
|
|
52
|
+
// The page that says the move is done stays until it is read and closed.
|
|
53
|
+
if (box?.dataset.kind === 'done') return
|
|
54
|
+
const signIn = s.enabled && (!s.linked || !s.approved) && (s.providers?.length || (!s.linked && s.online === false))
|
|
55
|
+
// The move is offered, not imposed: the board keeps working on its own numbers until its owner
|
|
56
|
+
// has looked at the new ones and said yes, and "Not now" really means not now.
|
|
57
|
+
const moving = s.enabled && s.linked && s.approved && s.needs_move && !later
|
|
58
|
+
if (!signIn && !moving) {
|
|
59
|
+
box?.remove()
|
|
60
|
+
document.body.classList.remove('gated')
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
if (box?.dataset.kind === 'move' && moving) return
|
|
64
|
+
if (!box) {
|
|
65
|
+
box = el('div', { id: 'account-gate', class: 'account-gate', role: 'dialog', 'aria-modal': 'true' })
|
|
66
|
+
document.body.append(box)
|
|
67
|
+
}
|
|
68
|
+
document.body.classList.add('gated')
|
|
69
|
+
box.dataset.kind = moving ? 'move' : 'gate'
|
|
70
|
+
box.replaceChildren(moving ? move(s) : s.linked ? waiting(s) : signInCard(s))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function move(s) {
|
|
74
|
+
const card = el('div', { class: 'gate-card wide' },
|
|
75
|
+
el('h2', {}, 'Move your board to k0 Cloud'),
|
|
76
|
+
el('p', {}, `You are signed in as ${s.email || s.name || 'you'}. The stories already on this machine were numbered here, ` +
|
|
77
|
+
'and from now on a number is one for your whole account, whichever repository and whichever machine. ' +
|
|
78
|
+
'Moving the board renumbers every story and epic once, in the order they were made, renames their files in .k0/, ' +
|
|
79
|
+
'and sends the board up. A copy of the database is kept first.'),
|
|
80
|
+
)
|
|
81
|
+
const table = el('div', { class: 'move-table' })
|
|
82
|
+
const buttons = el('div', { class: 'gate-buttons' })
|
|
83
|
+
const show = el('button', {
|
|
84
|
+
type: 'button',
|
|
85
|
+
onclick: async () => {
|
|
86
|
+
show.disabled = true
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch('/api/cloud/move', { cache: 'no-store' })
|
|
89
|
+
const plan = await res.json()
|
|
90
|
+
if (!res.ok) throw new Error(plan.error || `HTTP ${res.status}`)
|
|
91
|
+
table.replaceChildren(numbers(plan.rows, plan.texts))
|
|
92
|
+
go.hidden = false
|
|
93
|
+
show.hidden = true
|
|
94
|
+
} catch (err) {
|
|
95
|
+
show.disabled = false
|
|
96
|
+
note(card, err.message)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}, 'Show me the new numbers')
|
|
100
|
+
const go = el('button', {
|
|
101
|
+
type: 'button',
|
|
102
|
+
class: 'primary',
|
|
103
|
+
hidden: '',
|
|
104
|
+
onclick: async () => {
|
|
105
|
+
go.disabled = true
|
|
106
|
+
try {
|
|
107
|
+
const done = await post('move')
|
|
108
|
+
const box = $('#account-gate')
|
|
109
|
+
if (box) box.dataset.kind = 'done'
|
|
110
|
+
card.replaceChildren(
|
|
111
|
+
el('h2', {}, 'Moved'),
|
|
112
|
+
el('p', {}, `${done.moved} stories and epics now carry their account-wide numbers, starting at K${done.start}.`),
|
|
113
|
+
el('p', { class: 'gate-note' }, `The copy taken before the move: ${done.backup}`),
|
|
114
|
+
el('p', { class: 'gate-note' }, `Every old number beside its new one: ${done.map}`),
|
|
115
|
+
el('div', { class: 'gate-buttons' }, el('button', { type: 'button', class: 'primary', onclick: () => location.reload() }, 'Back to the board')),
|
|
116
|
+
)
|
|
117
|
+
} catch (err) {
|
|
118
|
+
go.disabled = false
|
|
119
|
+
note(card, err.message)
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}, 'Move it')
|
|
123
|
+
const notNow = el('button', {
|
|
124
|
+
type: 'button',
|
|
125
|
+
onclick: () => {
|
|
126
|
+
later = true
|
|
127
|
+
$('#account-gate')?.remove()
|
|
128
|
+
document.body.classList.remove('gated')
|
|
129
|
+
},
|
|
130
|
+
}, 'Not now')
|
|
131
|
+
buttons.append(show, go, notNow)
|
|
132
|
+
card.append(buttons, table)
|
|
133
|
+
return card
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Old number beside new, a repository at a time, the way the person reading it thinks of them. */
|
|
137
|
+
function numbers(rows, texts = 0) {
|
|
138
|
+
const byRepo = new Map()
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
const name = (r.project_path || '').split(/[\\/]/).filter(Boolean).pop() || r.project_path
|
|
141
|
+
if (!byRepo.has(name)) byRepo.set(name, [])
|
|
142
|
+
byRepo.get(name).push(r)
|
|
143
|
+
}
|
|
144
|
+
const wrap = el('div')
|
|
145
|
+
wrap.append(el('p', { class: 'gate-note' }, `${rows.length} in all, across ${byRepo.size} repositor${byRepo.size === 1 ? 'y' : 'ies'}.` +
|
|
146
|
+
(texts ? ` ${texts} pieces of text name a story by its number — a plan, a prompt, a decision — and are updated to the new one, inside the same repository.` : '')))
|
|
147
|
+
for (const [name, list] of byRepo) {
|
|
148
|
+
wrap.append(el('h3', {}, name))
|
|
149
|
+
const t = el('table')
|
|
150
|
+
t.append(el('tr', {}, el('th', {}, 'Was'), el('th', {}, 'Will be'), el('th', {}, '')))
|
|
151
|
+
for (const r of list) t.append(el('tr', {}, el('td', {}, r.old), el('td', {}, r.new), el('td', {}, `${r.kind === 'epic' ? 'Epic · ' : ''}${r.title}`)))
|
|
152
|
+
wrap.append(t)
|
|
153
|
+
}
|
|
154
|
+
return wrap
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function signInCard(s) {
|
|
158
|
+
const providers = s.providers || []
|
|
159
|
+
const card = el('div', { class: 'gate-card' },
|
|
160
|
+
el('h2', {}, 'Connect your account'),
|
|
161
|
+
el('p', {}, 'k0 keeps your board in k0 Cloud, so it survives a lost laptop, reaches your other machines and can be read from a browser. ' +
|
|
162
|
+
'It keeps working here without a connection and catches up when it has one.'),
|
|
163
|
+
)
|
|
164
|
+
if (!providers.length) {
|
|
165
|
+
card.append(el('p', { class: 'gate-note' }, 'k0 Cloud cannot be reached right now. Connect to the internet and this page will offer the way in.'))
|
|
166
|
+
return card
|
|
167
|
+
}
|
|
168
|
+
const row = el('div', { class: 'gate-buttons' })
|
|
169
|
+
for (const p of providers) {
|
|
170
|
+
row.append(el('button', {
|
|
171
|
+
type: 'button',
|
|
172
|
+
class: 'primary',
|
|
173
|
+
onclick: async (e) => {
|
|
174
|
+
e.currentTarget.disabled = true
|
|
175
|
+
try {
|
|
176
|
+
const { url } = await post('login', { provider: p })
|
|
177
|
+
location.href = url
|
|
178
|
+
} catch (err) {
|
|
179
|
+
e.currentTarget.disabled = false
|
|
180
|
+
note(card, err.message)
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}, `Continue with ${NAMES[p] || p}`))
|
|
184
|
+
}
|
|
185
|
+
card.append(row)
|
|
186
|
+
card.append(el('p', { class: 'gate-note' }, 'k0 is in early access: after you sign in, your account opens as soon as it has been approved.'))
|
|
187
|
+
return card
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function waiting(s) {
|
|
191
|
+
const card = el('div', { class: 'gate-card' },
|
|
192
|
+
el('h2', {}, 'You are on the list'),
|
|
193
|
+
el('p', {}, `Signed in as ${s.email || s.name || 'you'}. k0 is in early access, and your account opens as soon as it has been approved — this page notices by itself.`),
|
|
194
|
+
)
|
|
195
|
+
card.append(el('div', { class: 'gate-buttons' },
|
|
196
|
+
el('button', { type: 'button', onclick: () => post('logout').then(refresh) }, 'Use another account'),
|
|
197
|
+
))
|
|
198
|
+
return card
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function note(card, text) {
|
|
202
|
+
let n = $('.gate-error', card)
|
|
203
|
+
if (!n) card.append((n = el('p', { class: 'gate-error' })))
|
|
204
|
+
n.textContent = text
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── The chip on the bar ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
function chip(s) {
|
|
210
|
+
let c = $('#account')
|
|
211
|
+
if (!s.enabled || !s.linked) {
|
|
212
|
+
c?.remove()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
if (!c) {
|
|
216
|
+
c = el('div', { class: 'account', id: 'account' })
|
|
217
|
+
$('.topbar')?.append(c)
|
|
218
|
+
}
|
|
219
|
+
const state = s.online === false ? 'offline' : s.pending > 0 || s.provisional > 0 ? 'pending' : s.sync_error ? 'offline' : 'synced'
|
|
220
|
+
const said = {
|
|
221
|
+
synced: 'Everything is in k0 Cloud.',
|
|
222
|
+
pending: `${s.pending} change${s.pending === 1 ? '' : 's'} on the way to k0 Cloud.`,
|
|
223
|
+
offline: s.sync_error || 'k0 Cloud cannot be reached; the board carries on here and catches up later.',
|
|
224
|
+
}[state]
|
|
225
|
+
const menu = el('div', { class: 'menu account-menu', role: 'menu', hidden: '' },
|
|
226
|
+
el('p', {}, s.email || s.name || ''),
|
|
227
|
+
el('p', { class: 'account-said' }, said),
|
|
228
|
+
el('button', { type: 'button', role: 'menuitem', onclick: () => post('sync').then(refresh) }, 'Sync now'),
|
|
229
|
+
el('button', { type: 'button', role: 'menuitem', onclick: () => post('logout').then(refresh) }, 'Sign out'),
|
|
230
|
+
)
|
|
231
|
+
const button = el('button', {
|
|
232
|
+
type: 'button',
|
|
233
|
+
class: `account-chip ${state}`,
|
|
234
|
+
title: said,
|
|
235
|
+
'aria-label': `Account: ${said}`,
|
|
236
|
+
'aria-haspopup': 'true',
|
|
237
|
+
onclick: (e) => {
|
|
238
|
+
e.stopPropagation()
|
|
239
|
+
menu.hidden = !menu.hidden
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
s.avatar_url ? el('img', { src: s.avatar_url, alt: '', referrerpolicy: 'no-referrer' }) : el('span', { class: 'initial' }, (s.email || s.name || '?')[0].toUpperCase()),
|
|
243
|
+
el('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
244
|
+
)
|
|
245
|
+
c.replaceChildren(button, menu)
|
|
246
|
+
document.addEventListener('click', () => (menu.hidden = true), { once: true })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function refresh() {
|
|
250
|
+
try {
|
|
251
|
+
const s = await get()
|
|
252
|
+
last = s
|
|
253
|
+
gate(s)
|
|
254
|
+
chip(s)
|
|
255
|
+
} catch {
|
|
256
|
+
// The server itself is not answering: the board has its own way of saying that.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
refresh()
|
|
261
|
+
// Often enough to notice an approval or a sync finishing, rarely enough to cost nothing; and not
|
|
262
|
+
// at all while the tab is hidden, like everything else on this page.
|
|
263
|
+
setInterval(() => {
|
|
264
|
+
if (!document.hidden) refresh()
|
|
265
|
+
}, last?.linked && last?.approved ? 15000 : 5000)
|
package/web/awake.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// How many rounds in a row have to say "nothing moved" before the page stops asking so often.
|
|
2
|
+
// Five is about ten seconds of a still board, which is long enough that nobody is watching it
|
|
3
|
+
// happen and short enough that the slow pace is rarely the one you arrive back to.
|
|
4
|
+
const QUIET = 5
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A job that repeats only while somebody can see the page, and only as often as it is worth.
|
|
8
|
+
*
|
|
9
|
+
* Every page here polls: the board asks for the whole board, the file viewer asks git what
|
|
10
|
+
* changed. None of them ever asked whether anyone was looking, so a tab left open in another
|
|
11
|
+
* window went on asking for days. That is worse than it sounds, because asking is itself the
|
|
12
|
+
* signal: `/api/board` is what tells the server somebody is watching, and the server uses that to
|
|
13
|
+
* decide whether to sample the machine, walk the process table and ask git about every repository.
|
|
14
|
+
* One forgotten tab kept all of it running, for nothing.
|
|
15
|
+
*
|
|
16
|
+
* And a job may now say that nothing moved, by answering `false`. After a few such rounds the
|
|
17
|
+
* page drops to `resting`, and the first round that moves anything — or your coming back to the
|
|
18
|
+
* tab — puts it straight back. A job that says nothing at all is taken to have moved something,
|
|
19
|
+
* which is the safe way round: a page that does not know cannot be the one that goes quiet.
|
|
20
|
+
*
|
|
21
|
+
* Coming back runs the job at once — the first look at a tab you left yesterday must not show
|
|
22
|
+
* yesterday. The first start is left alone: pages that want a reading before the first interval
|
|
23
|
+
* already take it themselves.
|
|
24
|
+
*
|
|
25
|
+
* Gives back the way to stop it for good, which nothing needs yet and costs one line to offer.
|
|
26
|
+
*/
|
|
27
|
+
export function whileVisible(job, every, resting = every) {
|
|
28
|
+
let timer = null
|
|
29
|
+
let quiet = 0
|
|
30
|
+
|
|
31
|
+
const stop = () => {
|
|
32
|
+
if (timer) clearTimeout(timer)
|
|
33
|
+
timer = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const arm = (ms) => {
|
|
37
|
+
stop()
|
|
38
|
+
timer = setTimeout(round, ms)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function round() {
|
|
42
|
+
let moved = true
|
|
43
|
+
try {
|
|
44
|
+
moved = await job()
|
|
45
|
+
} catch {
|
|
46
|
+
/* the job fell over: it has told us nothing, so keep the working pace and try again */
|
|
47
|
+
}
|
|
48
|
+
quiet = moved === false ? quiet + 1 : 0
|
|
49
|
+
arm(quiet >= QUIET ? resting : every)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
document.addEventListener('visibilitychange', () => {
|
|
53
|
+
if (document.hidden) return stop()
|
|
54
|
+
quiet = 0
|
|
55
|
+
round()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// A page opened in a background tab is hidden from the first frame: it should not start either.
|
|
59
|
+
if (!document.hidden) arm(every)
|
|
60
|
+
|
|
61
|
+
return stop
|
|
62
|
+
}
|
package/web/base.css
CHANGED
|
@@ -109,6 +109,10 @@ dialog .x {
|
|
|
109
109
|
dialog .x svg { width: 17px; height: 17px; display: block; }
|
|
110
110
|
dialog .x:hover { background: #f0ede3; color: var(--ink); border: none; }
|
|
111
111
|
dialog label { display: block; margin-bottom: 18px; font-size: .82rem; font-weight: 600; color: var(--muted); }
|
|
112
|
+
/* `display: block` beats the browser's `[hidden]`, the same trap the marks on the top bar fall
|
|
113
|
+
into. A dialog can have a field that only exists sometimes — the epic, which is nothing at all
|
|
114
|
+
with the backlog switched off — and without this it would stand there whatever the answer was. */
|
|
115
|
+
dialog label[hidden] { display: none; }
|
|
112
116
|
dialog label small { display: block; margin-top: 5px; font-weight: 400; }
|
|
113
117
|
dialog input, dialog textarea {
|
|
114
118
|
display: block;
|
|
@@ -132,3 +136,96 @@ dialog .spacer { flex: 1; }
|
|
|
132
136
|
dialog.ask { width: min(380px, 88vw); padding: 22px 24px 18px; }
|
|
133
137
|
dialog.ask p { margin: 0 0 20px; font-size: 1rem; }
|
|
134
138
|
dialog.ask footer { margin: 0; }
|
|
139
|
+
|
|
140
|
+
/* ── The account ──────────────────────────────────────────────────────────────
|
|
141
|
+
The chip at the end of the bar, and the screen in front of the board until an account is
|
|
142
|
+
linked and let in. See web/account.js. */
|
|
143
|
+
.account { position: relative; flex: none; }
|
|
144
|
+
.account-chip {
|
|
145
|
+
position: relative;
|
|
146
|
+
width: 34px;
|
|
147
|
+
height: 34px;
|
|
148
|
+
padding: 0;
|
|
149
|
+
border-radius: 999px;
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: center;
|
|
153
|
+
overflow: visible;
|
|
154
|
+
}
|
|
155
|
+
.account-chip img { width: 100%; height: 100%; border-radius: 999px; object-fit: cover; }
|
|
156
|
+
.account-chip .initial { font-weight: 600; }
|
|
157
|
+
.account-chip .dot {
|
|
158
|
+
position: absolute;
|
|
159
|
+
right: -1px;
|
|
160
|
+
bottom: -1px;
|
|
161
|
+
width: 11px;
|
|
162
|
+
height: 11px;
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
border: 2px solid var(--bg);
|
|
165
|
+
background: var(--calm);
|
|
166
|
+
}
|
|
167
|
+
.account-chip.pending .dot { background: var(--warn); }
|
|
168
|
+
.account-chip.offline .dot { background: var(--muted); }
|
|
169
|
+
.account-menu {
|
|
170
|
+
position: absolute;
|
|
171
|
+
z-index: 6;
|
|
172
|
+
top: calc(100% + 6px);
|
|
173
|
+
right: 0;
|
|
174
|
+
min-width: 240px;
|
|
175
|
+
padding: 10px;
|
|
176
|
+
background: var(--paper);
|
|
177
|
+
border: 1px solid var(--line);
|
|
178
|
+
border-radius: 11px;
|
|
179
|
+
box-shadow: 0 12px 30px rgba(0, 0, 0, .18);
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
gap: 2px;
|
|
183
|
+
}
|
|
184
|
+
.account-menu[hidden] { display: none; }
|
|
185
|
+
.account-menu button {
|
|
186
|
+
width: 100%;
|
|
187
|
+
border: none;
|
|
188
|
+
background: none;
|
|
189
|
+
border-radius: 7px;
|
|
190
|
+
padding: 8px 11px;
|
|
191
|
+
text-align: left;
|
|
192
|
+
font-weight: 600;
|
|
193
|
+
}
|
|
194
|
+
.account-menu button:hover { background: #f0ede3; }
|
|
195
|
+
.account-menu p { margin: 0 0 6px; overflow-wrap: anywhere; }
|
|
196
|
+
.account-menu .account-said { color: var(--muted); font-size: 0.85rem; }
|
|
197
|
+
|
|
198
|
+
body.gated { overflow: hidden; }
|
|
199
|
+
.account-gate {
|
|
200
|
+
position: fixed;
|
|
201
|
+
inset: 0;
|
|
202
|
+
z-index: 1000;
|
|
203
|
+
display: flex;
|
|
204
|
+
align-items: center;
|
|
205
|
+
justify-content: center;
|
|
206
|
+
padding: 16px;
|
|
207
|
+
background: color-mix(in srgb, var(--bg) 92%, transparent);
|
|
208
|
+
backdrop-filter: blur(6px);
|
|
209
|
+
}
|
|
210
|
+
.gate-card {
|
|
211
|
+
width: 100%;
|
|
212
|
+
max-width: 520px;
|
|
213
|
+
padding: 28px;
|
|
214
|
+
border: 1px solid var(--line);
|
|
215
|
+
border-radius: 14px;
|
|
216
|
+
background: var(--paper);
|
|
217
|
+
box-shadow: 0 10px 40px rgb(0 0 0 / 0.08);
|
|
218
|
+
}
|
|
219
|
+
.gate-card h2 { margin: 0 0 10px; font-family: var(--hand); font-size: 2rem; font-weight: 600; }
|
|
220
|
+
.gate-card p { margin: 0 0 14px; line-height: 1.5; }
|
|
221
|
+
.gate-buttons { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0 14px; }
|
|
222
|
+
.gate-note { color: var(--muted); font-size: 0.9rem; }
|
|
223
|
+
.gate-error { color: var(--alert); font-size: 0.9rem; }
|
|
224
|
+
|
|
225
|
+
.auth-page main { max-width: 520px; margin: 15vh auto; padding: 0 16px; font-family: var(--sans); }
|
|
226
|
+
.gate-card.wide { max-width: 860px; max-height: calc(100vh - 32px); overflow: auto; }
|
|
227
|
+
.move-table h3 { margin: 18px 0 6px; font-size: 1rem; }
|
|
228
|
+
.move-table table { width: 100%; border-collapse: collapse; font-size: 0.9rem; table-layout: fixed; }
|
|
229
|
+
.move-table th:first-child, .move-table th:nth-child(2) { width: 90px; }
|
|
230
|
+
.move-table th, .move-table td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
231
|
+
.move-table td:first-child, .move-table td:nth-child(2) { font-family: var(--mono); white-space: nowrap; }
|