@ucsandman/legcli 0.7.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 (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
@@ -0,0 +1,670 @@
1
+ // Leg floor, the scheduler-eye view: what's running, waiting, queued, and
2
+ // which leases block whom. Polls /api/floor, /api/trunk and /api/sessions
3
+ // (for the instrument head's account rails) and refreshes on SSE.
4
+ (function () {
5
+ 'use strict'
6
+
7
+ const WAIT_LABELS = { approve: 'Approve', resume: 'Resume', kill: 'Kill' }
8
+ const WAIT_BTN_CLASS = { approve: 'btn btn-primary', resume: 'btn btn-secondary', kill: 'btn btn-danger' }
9
+
10
+ // `bind` starts as the address the reader actually reached this page on and is
11
+ // replaced by the server's own bind at init. It used to be the literal
12
+ // 127.0.0.1:4747 in two banner strings, so every board started with --port,
13
+ // every share and every second instance told a disconnected user to go and
14
+ // look at a port with nothing on it.
15
+ // NOTE: the ported head block below declares a LOCAL `state` in five of its
16
+ // functions for the account's one state word. Those five never touch this
17
+ // object; anything added inside them that needs it must reach for another
18
+ // name. test/board-updates.test.mjs pins `state.stopped` in this file by
19
+ // source text, which is why the module object keeps the name.
20
+ const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null }
21
+
22
+ function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
23
+
24
+ async function api(path, opts = {}) {
25
+ const headers = { 'Content-Type': 'application/json' }
26
+ const token = getToken()
27
+ if (token) headers.Authorization = `Bearer ${token}`
28
+ const res = await fetch(path, {
29
+ method: opts.method || 'GET',
30
+ headers,
31
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
32
+ })
33
+ const text = await res.text()
34
+ let data = null
35
+ if (text) { try { data = JSON.parse(text) } catch { data = null } }
36
+ if (!res.ok) {
37
+ const err = new Error((data && data.error) || `${res.status} ${res.statusText}`)
38
+ err.status = res.status
39
+ throw err
40
+ }
41
+ return data
42
+ }
43
+
44
+ function el(tag, attrs, children) {
45
+ const node = document.createElement(tag)
46
+ for (const [k, v] of Object.entries(attrs || {})) {
47
+ if (v == null || v === false) continue
48
+ if (k === 'class') node.className = v
49
+ else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v)
50
+ else if (v === true) node.setAttribute(k, '')
51
+ else node.setAttribute(k, v)
52
+ }
53
+ for (const c of [].concat(children || [])) {
54
+ if (c == null) continue
55
+ node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)
56
+ }
57
+ return node
58
+ }
59
+
60
+ // ---- 6.9 the system message ----
61
+ // src/board/board.js OWNS THIS COMPONENT; this is its MESSAGE_WORD table and
62
+ // its toast(), character for character. The floor used to build a second one
63
+ // into the same #toast box with the same .sysmsg-item class: no severity
64
+ // word, so the colour of a 7px mark was the only signal of how bad a message
65
+ // was, and the mark was hardcoded to danger whatever the tone.
66
+ // One message at a time, deduped by exact text. Errors never auto-dismiss,
67
+ // which is why `tone` defaults to danger: a bare call is an error, and an
68
+ // error the reader has not seen does not disappear.
69
+ const MESSAGE_WORD = { ok: 'done', warn: 'warning', danger: 'error' }
70
+ const message = { text: null, node: null, timer: null }
71
+
72
+ function dismissMessage() {
73
+ if (message.timer) { clearTimeout(message.timer); message.timer = null }
74
+ if (message.node) message.node.remove()
75
+ message.node = null
76
+ message.text = null
77
+ }
78
+
79
+ // the same words the board prints (board.js STATUS_LABELS): these two files
80
+ // have no module system, so the map is carried rather than imported
81
+ const STATUS_LABELS = {
82
+ running: 'running', handing_off: 'handing off', waiting_human: 'waiting human',
83
+ needs_approval: 'needs approval', paused: 'paused', queued: 'queued',
84
+ backlog: 'backlog', done: 'done', failed: 'failed', killed: 'killed',
85
+ }
86
+
87
+ function toast(msg, tone = 'danger') {
88
+ const text = String(msg)
89
+ const box = document.getElementById('toast')
90
+ if (!box || text === message.text) return
91
+ dismissMessage()
92
+ const word = MESSAGE_WORD[tone] || MESSAGE_WORD.danger
93
+ const item = el('div', { class: `sysmsg-item tone-${tone}` }, [
94
+ el('span', { class: 'status-word' }, [el('span', { class: `mark tone-${tone}`, 'aria-hidden': 'true' }), word]),
95
+ el('span', {}, [text]),
96
+ ])
97
+ if (tone === 'danger') {
98
+ item.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Dismiss message', onclick: dismissMessage }, ['Dismiss']))
99
+ } else {
100
+ message.timer = setTimeout(dismissMessage, 6000)
101
+ }
102
+ box.appendChild(item)
103
+ message.node = item
104
+ message.text = text
105
+ }
106
+
107
+ // A timestamp in a table cell is local, like every other clock on the page.
108
+ // formatTs used to slice the ISO-8601 string, which is UTC, and feed three
109
+ // columns sitting under a caption that reads "Times are local."
110
+ function formatTs(ts) { return clockAt(Date.parse(ts)) }
111
+
112
+ // the full text always goes in the cell's title too (6.7): CSS truncates
113
+ // with text-overflow: ellipsis, this no longer pre-truncates the string.
114
+ function formatLastEvent(last) {
115
+ if (!last) return 'no events yet'
116
+ return `${last.type}: ${last.summary}`
117
+ }
118
+
119
+ // ---- 6.1 the instrument head ------------------------------------------
120
+ // src/board/sessions.js OWNS THIS BLOCK. From ago() down to renderAccounts()
121
+ // it is that file's code character for character, with exactly one deliberate
122
+ // difference, marked at the line it is on (the id of the box the head is
123
+ // rendered into). The three board files cannot import from each other, and
124
+ // the previous arrangement - a comment promising "a literal copy" - drifted
125
+ // into six wrong answers on this page alone: a calm full bar for a walled
126
+ // account with no reading, aria-valuenow="0" for a window with no reading,
127
+ // "in 6d 23:55" against "in 6d 23h", "resets Sep 19" against "resets Sat
128
+ // 10:11 PM", "back 10:11 PM" for a wall four days out, and a provenance line
129
+ // with its source dropped. A change to any of it is a change to both files in
130
+ // the same commit.
131
+
132
+ const WIN_WORDS = { '5h': '5 hour', '7d': '7 day' }
133
+ const IDS = ['claude', 'codex', 'agy', 'fake']
134
+
135
+ // ---- times. The head prints `Times are local.` once, so no row repeats it ----
136
+ // THIS FILE OWNS THE TIME GRAMMAR FOR THE WHOLE BOARD. ago(), clockAt(),
137
+ // until() and elapsedClock() below are copied character for character into
138
+ // src/board/floor.js and src/board/board.js, which cannot import from here.
139
+ // One fact must never print in two formats across the two pages, so a change
140
+ // to any of these four is a change to all three files in the same commit.
141
+ function ago(ms) {
142
+ const s = Math.max(0, Math.floor(ms / 1000))
143
+ if (s < 60) return `${s}s`
144
+ const m = Math.floor(s / 60)
145
+ if (m < 60) return `${m}m`
146
+ const h = Math.floor(m / 60)
147
+ if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
148
+ const d = Math.floor(h / 24)
149
+ return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
150
+ }
151
+ // aria-valuetext is read out loud, so the gap is spelled out there and stays
152
+ // abbreviated in the visible cells. Both floor, so the spoken sentence and the
153
+ // cell beside it never differ by a minute on the same rail.
154
+ function spoken(ms) {
155
+ const m = Math.max(0, Math.floor(ms / 60000))
156
+ if (m < 60) return `${m} minute${m === 1 ? '' : 's'}`
157
+ const h = Math.floor(m / 60)
158
+ const rest = m % 60
159
+ if (h < 48) return `${h} hour${h === 1 ? '' : 's'}${rest ? ` ${rest} minute${rest === 1 ? '' : 's'}` : ''}`
160
+ return `${Math.floor(h / 24)} days`
161
+ }
162
+ function clockAt(ms) {
163
+ if (!Number.isFinite(ms)) return 'unknown'
164
+ const d = new Date(ms)
165
+ const out = Math.abs(ms - Date.now())
166
+ if (out < 20 * 3600000) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
167
+ if (out < 6 * 86400000) return d.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })
168
+ return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
169
+ }
170
+ function elapsedClock(ms) {
171
+ const s = Math.max(0, Math.floor(ms / 1000))
172
+ const pad = (n) => String(n).padStart(2, '0')
173
+ const mm = pad(Math.floor(s / 60) % 60)
174
+ const ss = pad(s % 60)
175
+ const h = Math.floor(s / 3600)
176
+ return h ? `${pad(h)}:${mm}:${ss}` : `${mm}:${ss}`
177
+ }
178
+ function until(epochS) { return Number.isFinite(epochS) ? clockAt(epochS * 1000) : 'unknown' }
179
+
180
+ function accountLabel(a) { return a.label || (a.account === 'default' ? a.agent : `${a.agent}/${a.account}`) }
181
+ function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
182
+
183
+ // ---- 6.1 the window rail ----------------------------------------------
184
+ // One state value per account drives the .acct modifier, every rail cell, the
185
+ // tier word and the spoken sentence, so those four cannot disagree.
186
+ function acctState(a) {
187
+ if (a.shared === false) return 'notshared'
188
+ if (a.loading) return 'loading'
189
+ if (Number.isFinite(a.limited_until) && a.limited_until * 1000 > Date.now()) return 'walled'
190
+ if (!a.five_hour && !a.seven_day) return 'noreading'
191
+ if (a.stale && a.agent !== 'agy') return 'stale'
192
+ return 'ok'
193
+ }
194
+ // What one window's numeral cell can say. A window with no reading prints the
195
+ // refusal, never a zero: a zero is a reading.
196
+ function windowState(a, w) {
197
+ const s = acctState(a)
198
+ if (s === 'notshared' || s === 'loading') return s
199
+ return w && Number.isFinite(w.pct) ? 'reading' : 'noreading'
200
+ }
201
+ // 6.1.6: a meter announces its name and its value text, so the whole answer
202
+ // goes in one sentence instead of four separate visible cells.
203
+ function railValueText(a, w, kind) {
204
+ const words = WIN_WORDS[kind] || kind
205
+ const state = windowState(a, w)
206
+ const parts = []
207
+ if (state === 'notshared') parts.push(`Usage for the ${words} window is not shared with guests.`)
208
+ else if (state === 'loading') parts.push(`No reading for the ${words} window has come back from /api/sessions yet.`)
209
+ else if (state === 'noreading') {
210
+ parts.push(a.agent === 'agy'
211
+ ? `agy publishes no usage percentage for the ${words} window. Leg sees the wall when agy hits it.`
212
+ : `No reading for the ${words} window yet.`)
213
+ } else {
214
+ parts.push(`${Math.round(w.pct)} percent of the ${words} window used.`)
215
+ if (Number.isFinite(w.resets_at)) parts.push(`Resets at ${until(w.resets_at)}, in ${spoken(w.resets_at * 1000 - Date.now())}.`)
216
+ }
217
+ if (acctState(a) === 'walled') parts.push(`${accountLabel(a)} is at its wall until ${until(a.limited_until)}, in ${spoken(a.limited_until * 1000 - Date.now())}.`)
218
+ const observed = Date.parse(a.observed_at || a.updated_at || '')
219
+ if (a.stale && a.agent !== 'agy' && Number.isFinite(observed)) parts.push(`Read at ${clockAt(observed)}, ${spoken(Date.now() - observed)} ago, stale.`)
220
+ return parts.join(' ')
221
+ }
222
+ function worstWindow(a) {
223
+ const w = [a.five_hour, a.seven_day].filter((x) => x && Number.isFinite(x.pct)).sort((x, y) => y.pct - x.pct)
224
+ return w[0] || a.five_hour || a.seven_day || null
225
+ }
226
+ // Drives the condensed head under 620px: walled first, then the highest
227
+ // percentage across both windows, ties broken by the soonest reset.
228
+ function closestToWall(accounts) {
229
+ const walled = (a) => (acctState(a) === 'walled' ? 1 : 0)
230
+ const worst = (a) => { const w = worstWindow(a); return w && Number.isFinite(w.pct) ? w.pct : -1 }
231
+ const soonest = (a) => {
232
+ const r = [a.five_hour, a.seven_day].filter((w) => w && Number.isFinite(w.resets_at)).map((w) => w.resets_at)
233
+ return r.length ? Math.min(...r) : Infinity
234
+ }
235
+ return [...accounts].sort((x, y) => (walled(y) - walled(x)) || (worst(y) - worst(x)) || (soonest(x) - soonest(y)))[0] || null
236
+ }
237
+
238
+ // The instrument. One track, one fill, one numeral, and a 2px notch cut
239
+ // through the bar where 85 percent sits. `minor` is the second window on a
240
+ // login: the same instrument at half the height and a smaller numeral, so
241
+ // the reader's eye lands on the window that is closest to a wall first.
242
+ function gauge(account, win, kind, { minor = false } = {}) {
243
+ const state = windowState(account, win)
244
+ const pct = state === 'reading' ? Math.max(0, Math.min(100, Math.round(win.pct))) : null
245
+ const words = WIN_WORDS[kind] || kind
246
+ const valueText = railValueText(account, win, kind)
247
+ const id = idOf(account.agent)
248
+ // severity paints INSIDE the track and nowhere else: the fill runs in the
249
+ // login's own identity colour up to 85 percent and in the over colour past
250
+ // it, so the bar shows the reserve it has eaten without colouring a word
251
+ const fill = pct === null ? null : el('span', {
252
+ class: 'gauge-fill',
253
+ style: pct <= 85
254
+ ? `width:${pct}%;background:var(--id-${id})`
255
+ : `width:${pct}%;background:linear-gradient(to right,var(--id-${id}) 0 ${((85 / pct) * 100).toFixed(2)}%,var(--danger) ${((85 / pct) * 100).toFixed(2)}% 100%)`,
256
+ })
257
+ // 6.1.6: a meter with no value is not a meter. aria-valuenow is required by
258
+ // role=meter, and an empty or absent one is announced as zero percent, which
259
+ // is the fabricated reading the visible cell refuses to print. With no value
260
+ // the track drops the role and carries the same sentence as its name.
261
+ const semantics = pct === null
262
+ ? { role: 'img', 'aria-label': `${accountLabel(account)}, ${words} window. ${valueText}` }
263
+ : {
264
+ role: 'meter',
265
+ 'aria-valuemin': '0',
266
+ 'aria-valuemax': '100',
267
+ 'aria-valuenow': String(pct),
268
+ 'aria-label': `${accountLabel(account)}, ${words} window`,
269
+ 'aria-valuetext': valueText,
270
+ }
271
+ if (pct === null && state !== 'reading') {
272
+ // no number exists, so no instrument is drawn. A track with nothing in it
273
+ // is a reading of zero to anyone glancing at it.
274
+ return el('div', { class: 'gauge gauge--none' }, [
275
+ el('span', { class: 'gauge-label' }, [words]),
276
+ el('span', { class: 'gauge-read--none', ...semantics }, [state === 'notshared' ? 'not shared' : state === 'loading' ? 'reading' : 'no reading']),
277
+ ])
278
+ }
279
+ const readout = [el('span', { class: 'gauge-read' }, [`${pct}%`])]
280
+ const observed = Date.parse(account.observed_at || account.updated_at || '')
281
+ if (!minor && account.stale && account.agent !== 'agy' && Number.isFinite(observed)) {
282
+ readout.push(el('span', { class: 'gauge-note' }, [`measured ${ago(Date.now() - observed)} ago`]))
283
+ } else if (!minor && win && Number.isFinite(win.resets_at)) {
284
+ readout.push(el('span', { class: 'gauge-note' }, [`resets ${until(win.resets_at)}`]))
285
+ }
286
+ return el('div', { class: `gauge${minor ? ' gauge--minor' : ''}` }, [
287
+ el('span', { class: 'gauge-label' }, [words]),
288
+ el('div', { class: 'gauge-track', ...semantics }, [fill, el('span', { class: 'gauge-post', style: 'left:85%' })]),
289
+ el('div', { class: 'gauge-readout' }, readout),
290
+ ])
291
+ }
292
+
293
+ // A login is a raised object, and how much surface it gets is the design
294
+ // saying how much it matters. The login carrying the terminals gets the wide
295
+ // lit panel with both of its windows drawn; a login with one fact to report
296
+ // gets a half panel; a login that publishes no figure draws no instrument at
297
+ // all, because an empty track reads as a measurement of zero.
298
+ function loginPanel(a, { lit = false } = {}) {
299
+ const state = acctState(a)
300
+ const id = idOf(a.agent)
301
+ const panel = el('article', { class: `panel${lit ? ' panel--lit' : ''}`, 'aria-label': `${accountLabel(a)} usage` })
302
+ const live = a.live ? `${a.live} terminal${a.live === 1 ? '' : 's'} working` : 'no terminals'
303
+ panel.appendChild(el('div', { class: 'panel-head' }, [
304
+ el('span', { class: 'who' }, [el('span', { class: `dot id-${id}` }), el('span', { class: `acct-name id-${id}` }, [accountLabel(a)])]),
305
+ el('span', { class: 'who-note' }, [live]),
306
+ ]))
307
+
308
+ if (state === 'notshared' || state === 'loading') {
309
+ panel.appendChild(gauge(a, null, '5h'))
310
+ panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [state === 'notshared' ? 'Usage for this login is not shared with guests.' : 'Waiting for the first reading.']))
311
+ return panel
312
+ }
313
+
314
+ // A login at its wall is reporting the loudest fact it has, and it is a
315
+ // known one: say that instead of "no reading", whether or not a percentage
316
+ // ever came back.
317
+ if (state === 'walled' && !a.five_hour && !a.seven_day) {
318
+ panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
319
+ panel.appendChild(el('p', { class: 'reading-sub' }, [Number.isFinite(a.limited_until)
320
+ ? `Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`
321
+ : `Nothing runs on ${accountLabel(a)} until it resets.`]))
322
+ return panel
323
+ }
324
+
325
+ // agy publishes no usage figure, ever, so there is nothing to draw and the
326
+ // panel says so in a sentence instead of drawing an empty instrument
327
+ if (!a.five_hour && !a.seven_day) {
328
+ panel.appendChild(gauge(a, null, '5h'))
329
+ panel.appendChild(el('p', { class: 'reading-sub reading-sub--lead' }, [a.agent === 'agy'
330
+ ? 'agy publishes no usage figure, ever. Leg shows its terminals and their elapsed time instead.'
331
+ : `No reading has come back from ${accountLabel(a)} yet.`]))
332
+ return panel
333
+ }
334
+
335
+ // the window closest to a wall is drawn first and full size; the other is
336
+ // the same instrument at half height, so which one to read is not a question
337
+ const [first, second] = [a.seven_day, a.five_hour].every((w) => w && Number.isFinite(w.pct))
338
+ ? (a.seven_day.pct >= a.five_hour.pct ? [[a.seven_day, '7d'], [a.five_hour, '5h']] : [[a.five_hour, '5h'], [a.seven_day, '7d']])
339
+ : [[a.five_hour && Number.isFinite(a.five_hour.pct) ? a.five_hour : a.seven_day, a.five_hour && Number.isFinite(a.five_hour.pct) ? '5h' : '7d'], null]
340
+ panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, first[0], first[1])]))
341
+ if (second) panel.appendChild(el('div', { class: 'gauge-block' }, [gauge(a, second[0], second[1], { minor: true })]))
342
+
343
+ if (state === 'walled') {
344
+ panel.appendChild(el('p', { class: 'reading' }, ['At the wall']))
345
+ panel.appendChild(el('p', { class: 'reading-sub' }, [`Back ${until(a.limited_until)}. Nothing runs on ${accountLabel(a)} until then.`]))
346
+ }
347
+ return panel
348
+ }
349
+
350
+ function renderAccounts(accounts) {
351
+ // THE ONE LINE THAT DIFFERS FROM sessions.js: this page's head box
352
+ const box = document.getElementById('floor-accounts')
353
+ if (!box) return
354
+ box.textContent = ''
355
+ const list = accounts || []
356
+ if (!list.length) return
357
+ // The floor has no verdict sentence: it is the scheduler's view, and its own
358
+ // heading says what page you are on. The login panels are identical to the
359
+ // board's, which is the contract that matters — a percentage, a reset or a
360
+ // wall can never read two ways across the two pages.
361
+ // the same lead rule as the board: the login carrying the terminals, else
362
+ // the one closest to a wall. Leading with a walled login that has no reading
363
+ // put a panel with no instrument in the largest slot.
364
+ const lead = list.find((a) => a.live) || closestToWall(list) || list[0]
365
+ const rest = list.filter((a) => a !== lead)
366
+ box.appendChild(loginPanel(lead, { lit: true }))
367
+ if (rest.length === 1) box.appendChild(loginPanel(rest[0]))
368
+ else if (rest.length) box.appendChild(el('div', { class: 'logins-pair' }, rest.map((a) => loginPanel(a))))
369
+ }
370
+
371
+ // the head is kept so a breakpoint change can redraw it without a refetch,
372
+ // the way sessions.js redraws from its cached view
373
+ let lastAccounts = []
374
+ function renderHead(accounts) { lastAccounts = accounts || lastAccounts; renderAccounts(lastAccounts) }
375
+
376
+ // The sentence a region prints in place of its rows. Each one says what the
377
+ // region holds AND what puts something in it, the shape index.html's own
378
+ // empty states use: four of the five used to say only that there was nothing
379
+ // there, which teaches a first-time reader nothing about what a lease is or
380
+ // how a card reaches the queue.
381
+ function emptyRow(text) { return el('p', { class: 'empty-line' }, [text]) }
382
+
383
+ // renderTable: table-layout: fixed with real column widths (board.css) means
384
+ // an empty table would otherwise show a header over a void; instead the
385
+ // <thead> hides and one emptyRow() sentence takes its place under the head.
386
+ function renderTable(bodyId, list, buildRow, emptyText) {
387
+ const body = document.getElementById(bodyId)
388
+ body.textContent = ''
389
+ const table = body.parentElement
390
+ const section = table && table.parentElement
391
+ if (section) { const old = section.querySelector('.empty-line'); if (old) old.remove() }
392
+ const thead = table && table.querySelector('thead')
393
+ if (!list.length) {
394
+ if (thead) thead.hidden = true
395
+ if (section) section.appendChild(emptyRow(emptyText))
396
+ return
397
+ }
398
+ if (thead) thead.hidden = false
399
+ for (const item of list) for (const row of [].concat(buildRow(item))) body.appendChild(row)
400
+ }
401
+
402
+ function setRegionMeta(headId, text) {
403
+ const head = document.getElementById(headId)
404
+ const meta = head && head.parentElement && head.parentElement.querySelector('.region-meta')
405
+ if (meta) meta.textContent = text
406
+ }
407
+
408
+ async function runFloorAction(id, action) {
409
+ try {
410
+ await api(`/api/cards/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
411
+ refreshFloor()
412
+ } catch (err) {
413
+ toast(err.message)
414
+ }
415
+ }
416
+
417
+ function renderHeader(data) {
418
+ // repo names only on the floor (the full path is the tooltip): a shared
419
+ // floor should not print every operator's home directory
420
+ const reposEl = document.getElementById('repos-list')
421
+ reposEl.textContent = data.repos && data.repos.length ? data.repos.map((r) => String(r).split(/[\\/]/).filter(Boolean).pop() || r).join(', ') : '(none)'
422
+ reposEl.title = data.repos && data.repos.length ? data.repos.join('\n') : ''
423
+ const sched = data.scheduler || {}
424
+ document.getElementById('sched-status').textContent = `scheduler ${sched.running ? 'running' : 'stopped'}, ${sched.max_concurrent ?? '?'} max`
425
+ document.getElementById('count-running').textContent = String(data.counts.running)
426
+ document.getElementById('count-queued').textContent = String(data.counts.queued)
427
+ document.getElementById('count-waiting').textContent = String(data.counts.waiting)
428
+ document.getElementById('count-done').textContent = String(data.counts.done)
429
+ }
430
+
431
+ function renderRunning(list) {
432
+ renderTable('running-body', list, (r) => el('tr', {}, [
433
+ el('td', { 'data-label': 'Card', title: r.title || r.card_id }, [r.title || r.card_id]),
434
+ el('td', { 'data-label': 'Station' }, [r.station || '']),
435
+ el('td', { 'data-label': 'Agent / leg' }, [el('span', { class: 'chip' }, [`${r.adapter || '?'} leg ${r.leg}`])]),
436
+ el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(r.leases && r.leases.length ? r.leases : ['**']).join(', ')])]),
437
+ el('td', { 'data-label': 'Last event', title: formatLastEvent(r.last_event) }, [formatLastEvent(r.last_event)]),
438
+ el('td', { 'data-label': 'Elapsed' }, [el('span', { class: 'elapsed' }, [elapsedClock(r.elapsed_ms || 0)])]),
439
+ el('td', { class: 'row-actions', 'data-label': 'Actions' }, [
440
+ el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Pause ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'pause') }, ['Pause']),
441
+ el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Kill ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'kill') }, ['Kill']),
442
+ ]),
443
+ ]), 'Nothing running. A queued card starts here when its leases are free and the scheduler has a slot.')
444
+ }
445
+
446
+ function renderWaiting(list) {
447
+ renderTable('waiting-body', list, (w) => {
448
+ const btns = (w.actions || [])
449
+ .filter((a) => WAIT_LABELS[a])
450
+ .map((a) => el('button', { type: 'button', class: WAIT_BTN_CLASS[a], 'aria-label': `${WAIT_LABELS[a]} ${w.title || w.card_id}`, onclick: () => runFloorAction(w.card_id, a) }, [WAIT_LABELS[a]]))
451
+ return el('tr', {}, [
452
+ el('td', { 'data-label': 'Card', title: w.title || w.card_id }, [w.title || w.card_id]),
453
+ el('td', { 'data-label': 'Station' }, [w.station || '']),
454
+ el('td', { 'data-label': 'Status' }, [STATUS_LABELS[w.status] || w.status]),
455
+ el('td', { 'data-label': 'Since' }, [el('span', { class: 'chip' }, [formatTs(w.since)])]),
456
+ el('td', { class: 'row-actions', 'data-label': 'Actions' }, btns),
457
+ ])
458
+ }, 'No cards waiting on a human. A card lands here when a station asks for approval or a run stops for an answer.')
459
+ }
460
+
461
+ function renderQueued(list) {
462
+ renderTable('queued-body', list, (q) => el('tr', {}, [
463
+ el('td', { 'data-label': 'Card', title: q.title || q.card_id }, [q.title || q.card_id]),
464
+ el('td', { 'data-label': 'Station' }, [q.station || '']),
465
+ el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(q.leases && q.leases.length ? q.leases : ['**']).join(', ')])]),
466
+ // the scheduler's blocked_by summary already opens with "blocked by"
467
+ // (scheduler.mjs), and the column is headed "Blocked by": prefixing it
468
+ // here printed `blocked by blocked by card "X" on src/**`
469
+ el('td', { 'data-label': 'Blocked by', title: q.blocked_by || '' }, [q.blocked_by || '']),
470
+ ]), 'Nothing queued. New card on the board queues one; it waits here until a slot and its leases are free.')
471
+ }
472
+
473
+ function renderLeases(data) {
474
+ renderTable('leases-body', data.leases, (l) => {
475
+ const rows = [el('tr', {}, [
476
+ el('td', { 'data-label': 'Lease' }, [el('span', { class: 'chip' }, [l.lease])]),
477
+ el('td', { 'data-label': 'Card', title: l.card_id }, [l.card_id]),
478
+ el('td', { 'data-label': 'Station' }, [l.station || '']),
479
+ el('td', { 'data-label': 'Since' }, [el('span', { class: 'chip' }, [formatTs(l.since)])]),
480
+ ])]
481
+ const blocked = (data.queued || []).filter((q) => q.blocked_by && q.blocked_by.includes(l.lease))
482
+ if (blocked.length) {
483
+ const names = blocked.map((q) => q.title || q.card_id).join(', ')
484
+ rows.push(el('tr', { class: 'lease-blocked-row' }, [el('td', { colspan: '4', 'data-label': 'Note', title: names }, [`queued, blocked: ${names}`])]))
485
+ }
486
+ return rows
487
+ }, 'No leases held. A running card reserves the files it will change, so an overlapping card waits instead of clashing.')
488
+ }
489
+
490
+ function renderTrunkTable(data) {
491
+ renderTable('trunk-body', data.landed, (t) => el('tr', {}, [
492
+ el('td', { 'data-label': 'Time' }, [el('span', { class: 'chip' }, [formatTs(t.ts)])]),
493
+ el('td', { 'data-label': 'Card', title: t.title || t.card_id }, [t.title || t.card_id]),
494
+ el('td', { 'data-label': 'Summary', title: t.summary || '' }, [t.summary || '']),
495
+ ]), 'Nothing landed in the last hour.')
496
+ }
497
+
498
+ // the floor is owner-only: a guest (or anyone whose token has rotated) would
499
+ // otherwise get two toasts every two seconds for as long as the tab is open
500
+ function boardHref(href) {
501
+ try {
502
+ const token = new URL(href).searchParams.get('token')
503
+ return token ? `/?token=${encodeURIComponent(token)}` : '/'
504
+ } catch { return '/' }
505
+ }
506
+
507
+ function lockOut(message) {
508
+ if (state.stopped) return
509
+ state.stopped = true
510
+ for (const t of state.timers) clearInterval(t)
511
+ state.timers = []
512
+ if (state.es) { try { state.es.close() } catch { /* ignore */ } }
513
+ document.getElementById('banner').hidden = true
514
+ document.getElementById('sse-dot').className = 'sse-rule'
515
+ document.getElementById('sse-text').textContent = 'stopped'
516
+ const main = document.querySelector('.shell')
517
+ main.textContent = ''
518
+ main.appendChild(el('section', { class: 'region' }, [
519
+ el('h2', { class: 'region-title' }, ['Floor unavailable']),
520
+ el('p', { class: 'empty-line' }, [message]),
521
+ el('p', { class: 'empty-line' }, [
522
+ el('a', { href: boardHref(location.href) }, ['Open the board']),
523
+ ' to set a token, or ask the owner of this machine for a share link.',
524
+ ]),
525
+ ]))
526
+ }
527
+
528
+ async function refreshFloor() {
529
+ const request = ++state.floorRequest
530
+ try {
531
+ const data = await api('/api/floor')
532
+ if (state.stopped || request !== state.floorRequest) return
533
+ state.lastReadingAt = new Date()
534
+ renderHeader(data)
535
+ renderRunning(data.running)
536
+ renderWaiting(data.waiting)
537
+ renderQueued(data.queued)
538
+ renderLeases(data)
539
+ setRegionMeta('running-head', `${data.running.length} running`)
540
+ setRegionMeta('waiting-head', `${data.waiting.length} waiting on a human`)
541
+ setRegionMeta('queued-head', `${data.queued.length} queued`)
542
+ setRegionMeta('leases-head', `${data.leases.length} held`)
543
+ } catch (err) {
544
+ if (state.stopped || request !== state.floorRequest) return
545
+ if (err.status === 401 || err.status === 403) return lockOut(err.message)
546
+ toast(err.message)
547
+ }
548
+ }
549
+
550
+ async function refreshTrunk() {
551
+ const request = ++state.trunkRequest
552
+ try {
553
+ const data = await api('/api/trunk?since=1h')
554
+ if (state.stopped || request !== state.trunkRequest) return
555
+ state.lastReadingAt = new Date()
556
+ renderTrunkTable(data)
557
+ setRegionMeta('trunk-lane-head', `${data.landed.length} landed in the last hour`)
558
+ } catch (err) {
559
+ if (state.stopped || request !== state.trunkRequest) return
560
+ if (err.status === 401 || err.status === 403) return lockOut(err.message)
561
+ toast(err.message)
562
+ }
563
+ }
564
+
565
+ // the instrument head's accounts are not on /api/floor's payload; /api/sessions
566
+ // carries them (sessionsView()) and is already reachable by an owner token.
567
+ async function refreshHead() {
568
+ const request = ++state.headRequest
569
+ try {
570
+ const data = await api('/api/sessions')
571
+ if (state.stopped || request !== state.headRequest) return
572
+ state.lastReadingAt = new Date()
573
+ renderHead(data.accounts || [])
574
+ } catch (err) {
575
+ if (state.stopped || request !== state.headRequest) return
576
+ if (err.status === 401 || err.status === 403) return lockOut(err.message)
577
+ }
578
+ }
579
+
580
+ // The board is pushed a card for every write under its directory, log bytes
581
+ // included, so a busy agent turns an unconditional refresh-per-push into a
582
+ // continuous request stream against a single-threaded local server that is
583
+ // also serving this page's SSE. One pending refresh at a time, the shape
584
+ // board.js uses for its drawer.
585
+ const FLOOR_REFRESH_MS = 250
586
+ function scheduleFloorRefresh() {
587
+ if (state.pendingFloor) return
588
+ state.pendingFloor = setTimeout(() => { state.pendingFloor = null; refreshFloor() }, FLOOR_REFRESH_MS)
589
+ }
590
+
591
+ // ---- SSE ----
592
+ function setSseState(s) {
593
+ const dot = document.getElementById('sse-dot')
594
+ const text = document.getElementById('sse-text')
595
+ const banner = document.getElementById('banner')
596
+ dot.className = `sse-rule is-${s}`
597
+ const retrySec = Math.max(1, Math.round((state.retryMs || 1000) / 1000))
598
+ text.textContent = s === 'live' ? 'live' : s === 'reconnecting' ? `reconnecting, next attempt in ${retrySec}s` : 'connecting'
599
+ banner.hidden = s === 'live'
600
+ if (s === 'reconnecting') {
601
+ banner.textContent = state.lastReadingAt ? `Reconnecting to Leg. Last reading ${clockAt(state.lastReadingAt.getTime())}.` : `Reconnecting to Leg on ${state.bind}.`
602
+ } else if (s === 'connecting') {
603
+ banner.textContent = `Connecting to Baton on ${state.bind}.`
604
+ }
605
+ }
606
+
607
+ function connectSse() {
608
+ if (state.stopped) return
609
+ if (state.es) { try { state.es.close() } catch { /* ignore */ } }
610
+ const request = ++state.sseRequest
611
+ setSseState('connecting')
612
+ const token = getToken()
613
+ const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events'
614
+ const es = new EventSource(url)
615
+ state.es = es
616
+ es.addEventListener('hello', () => {
617
+ if (state.es !== es || request !== state.sseRequest) return
618
+ state.retryMs = 1000
619
+ setSseState('live')
620
+ refreshFloor()
621
+ refreshTrunk()
622
+ refreshHead()
623
+ })
624
+ es.addEventListener('card', () => { if (state.es === es && request === state.sseRequest) scheduleFloorRefresh() })
625
+ es.addEventListener('event', (e) => {
626
+ if (state.es !== es || request !== state.sseRequest) return
627
+ scheduleFloorRefresh()
628
+ const data = JSON.parse(e.data)
629
+ if (data.type === 'landed') refreshTrunk()
630
+ })
631
+ es.onerror = () => {
632
+ if (state.es !== es || request !== state.sseRequest) return
633
+ setSseState('reconnecting')
634
+ try { es.close() } catch { /* ignore */ }
635
+ const wait = state.retryMs || 1000
636
+ setTimeout(() => { if (request === state.sseRequest) connectSse() }, wait)
637
+ state.retryMs = Math.min(wait * 2, 15000)
638
+ }
639
+ }
640
+
641
+ // the address in the banners comes from the server, so both pages name the
642
+ // same one; location.host already covers it if health is unreachable
643
+ async function loadBind() {
644
+ try {
645
+ const data = await api('/api/health')
646
+ if (data && data.bind) state.bind = `${data.bind}:${data.port}`
647
+ } catch { /* the page's own host is already a correct answer */ }
648
+ }
649
+
650
+ function init() {
651
+ loadBind()
652
+ connectSse()
653
+ refreshFloor()
654
+ refreshTrunk()
655
+ refreshHead()
656
+ // a backgrounded tab kept polling three endpoints every two seconds forever;
657
+ // the SSE stream wakes it with the work it missed when it comes back
658
+ state.timers.push(
659
+ setInterval(() => { if (!document.hidden) refreshFloor() }, 2000),
660
+ setInterval(() => { if (!document.hidden) { refreshTrunk(); refreshHead() } }, 2000),
661
+ )
662
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && !state.stopped) { refreshFloor(); refreshTrunk(); refreshHead() } })
663
+ }
664
+
665
+ document.addEventListener('DOMContentLoaded', init)
666
+
667
+ // test seam: node:test runs this file with a stub document; in a browser
668
+ // there is no `module`
669
+ if (typeof module !== 'undefined') module.exports = { boardHref }
670
+ })()