@ucsandman/legcli 0.12.0 → 0.13.1

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.
@@ -0,0 +1,343 @@
1
+ // The one-line background entry (redesign C.2), for every page that can start
2
+ // work: the board under its Background panel, the floor under its capacity
3
+ // strip.
4
+ //
5
+ // THIS FILE OWNS THE ENTRY ROW. It was board.js's, and the floor had no way to
6
+ // start anything at all; a second copy there would have been a second Start
7
+ // button posting a slightly different body. It is a plain script like the
8
+ // other four, so it publishes one factory on `window.legEntry` and the page
9
+ // hands it what it cannot know: its own el/api/toast, the live payloads it
10
+ // polls, and what "More settings" means on that page.
11
+ //
12
+ // const entry = window.legEntry.create({ el, api, toast, host, ... })
13
+ // entry.render()
14
+ //
15
+ // `host` is the caller's own live state object, read on every render and never
16
+ // copied: { sessions, cards (Map), repoTrunks, preferences, adapters, models }.
17
+ (function () {
18
+ 'use strict'
19
+
20
+ function create(deps) {
21
+ const { el, api, toast } = deps
22
+ const host = deps.host
23
+ const boxId = deps.boxId || 'card-entry'
24
+ const isGuest = deps.isGuest || (() => false)
25
+ const onCreated = deps.onCreated || (() => {})
26
+ const onMoreSettings = deps.onMoreSettings || (() => {})
27
+
28
+ // Its three nouns (repo, ladder, workflow) are inferred and are buttons
29
+ // that swap for a select in place; nothing here persists past a page
30
+ // reload. `model` is a THIRD state: undefined means "whatever the rung
31
+ // carries", '' means "the provider's own default", and an id means that
32
+ // model. A plain null could not tell the first two apart, and they post
33
+ // different chains.
34
+ const entryState = { task: '', repo: null, ladderStart: 0, model: undefined, pipeline: 'build', editing: null }
35
+ const PIPELINE_WORDS = { build: 'build only', 'build-land': 'build and land', factory: 'plan, build, review and land' }
36
+
37
+ // Only a terminal that carries a `repo` is offered: /api/cards refuses a path
38
+ // that is not a git repository root, so a terminal whose cwd is a plain
39
+ // folder would put a value in this field that Start can only ever reject.
40
+ function knownRepos() {
41
+ const seen = new Map()
42
+ for (const s of host.sessions || []) if (s.repo && !seen.has(s.repo)) seen.set(s.repo, s.repo_name || s.repo)
43
+ for (const c of (host.cards || new Map()).values()) if (c.repo && !seen.has(c.repo)) seen.set(c.repo, c.repo_name || c.repo)
44
+ return [...seen].map(([path, name]) => ({ path, name }))
45
+ }
46
+
47
+ // C.2: the most recently focused terminal's repo, else the last card's, else
48
+ // the first terminal's.
49
+ function inferRepo() {
50
+ const sessions = (host.sessions || []).filter((s) => s.repo)
51
+ const byFocus = sessions.length ? [...sessions].sort((a, b) => (Date.parse(b.last_activity) || 0) - (Date.parse(a.last_activity) || 0))[0] : null
52
+ if (byFocus) return { path: byFocus.repo, name: byFocus.repo_name || byFocus.repo }
53
+ const cards = [...(host.cards || new Map()).values()].sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
54
+ if (cards[0] && cards[0].repo) return { path: cards[0].repo, name: cards[0].repo_name || cards[0].repo }
55
+ if (sessions[0]) return { path: sessions[0].repo, name: sessions[0].repo_name || sessions[0].repo }
56
+ return null
57
+ }
58
+
59
+ function entryRepo() { return entryState.repo || inferRepo() }
60
+
61
+ // Every agent this machine really has, test adapters excluded: a ladder that
62
+ // hands off to a script is not a fallback.
63
+ function realAdapters() {
64
+ return (host.adapters || []).filter((a) => !a.fake).map((a) => a.name)
65
+ }
66
+
67
+ // The agents a SAVED ladder may name, which is not the same list as the
68
+ // agents that can run a card: preferences takes a closed list
69
+ // (ALL_HANDOFF_AGENTS, src/preferences.mjs) and refuses the whole array over
70
+ // one rung it does not know, so a custom adapter added with `leg adapter
71
+ // add` runs cards happily and can never be a saved rung. Settings' own
72
+ // picker holds the same four at src/board/sessions.js LADDER_AGENTS; this
73
+ // copy is the one the New card dialog saves through.
74
+ const LADDER_AGENTS = ['claude', 'codex', 'agy', 'grok']
75
+ function ladderAgents() { return [...LADDER_AGENTS] }
76
+
77
+ const rungCost = (agent) => (agent === 'agy' ? 'free' : agent === 'grok' ? 'metered' : 'plan')
78
+ const asRung = (agent, model) => ({ agent, account: 'default', model: model || null, when: 'always', cost: rungCost(agent) })
79
+
80
+ // The ladder this board acts on, with two fallbacks under the real one.
81
+ // Wes's report was "I'm not able to configure any agents for the background
82
+ // stuff": the entry row read `handoff_ladder` alone and said "no agent is
83
+ // configured" on an install that had three agents and a working
84
+ // `handoff_order`, because a preferences.json written by a Leg older than
85
+ // 0.12.0 carries only the order. The server writes both keys now
86
+ // (preferences.mjs ladderFor), so this is for the file that is already on
87
+ // disk, for an older server behind a newer board, and for a settings call
88
+ // that never answered at all -- in which case the adapters themselves are
89
+ // the honest list. A rung with no model means "whatever that CLI runs".
90
+ function ladderFromPreferences(prefs) {
91
+ if (prefs && Array.isArray(prefs.handoff_ladder) && prefs.handoff_ladder.length) return prefs.handoff_ladder
92
+ if (prefs && Array.isArray(prefs.handoff_order) && prefs.handoff_order.length) return prefs.handoff_order.map((a) => asRung(a, null))
93
+ return realAdapters().map((a) => asRung(a, null))
94
+ }
95
+
96
+ // the ladder, skipping a credits/metered rung while may_spend is off:
97
+ // a card never starts on a rung that bills without asking (-p mode does)
98
+ function ladderRungs() {
99
+ const prefs = host.preferences
100
+ const maySpend = !!(prefs && prefs.may_spend)
101
+ return ladderFromPreferences(prefs).filter((r) => maySpend || !['credits', 'metered'].includes(r.cost))
102
+ }
103
+
104
+ function ladderLabel(r) { return r.model ? `${r.agent}/${r.model}` : r.agent }
105
+
106
+ // An empty list has two different causes and they need two different
107
+ // sentences: nothing is installed, or everything installed bills and
108
+ // spending is off. "No agent is configured" for the second one sent the
109
+ // reader to a settings page that had nothing wrong with it.
110
+ function ladderSentence(rungs) {
111
+ if (rungs.length) return rungs.map(ladderLabel).join(' then ')
112
+ return ladderFromPreferences(host.preferences).length
113
+ ? 'every agent here bills by the token, and spending is off'
114
+ : 'no agent is configured'
115
+ }
116
+
117
+ // The models /api/models published for this provider. An unknown provider or
118
+ // a catalog that never answered is an empty list, and the select then offers
119
+ // the provider default alone, which is what a bare `leg <agent>` already runs.
120
+ function modelsFor(agent) {
121
+ const catalog = host.models || {}
122
+ return Array.isArray(catalog[agent]) ? catalog[agent] : []
123
+ }
124
+
125
+ // The first option of every model select. It names the model the CLI would
126
+ // pick when Leg passes no flag, when the catalog says which one that is.
127
+ function defaultModelLabel(agent) {
128
+ const chosen = modelsFor(agent).find((m) => m.default)
129
+ return chosen ? `${agent} default (${chosen.id})` : `${agent} default`
130
+ }
131
+
132
+ // A provider + model select pair, wired so the model list follows the
133
+ // provider. `onPick(agent, model)` fires on either; `model` is '' for the
134
+ // provider default.
135
+ function modelSelect(agent, model, label, onPick) {
136
+ const select = el('select', { 'aria-label': label })
137
+ const fill = () => {
138
+ select.textContent = ''
139
+ select.appendChild(el('option', { value: '' }, [defaultModelLabel(agent)]))
140
+ for (const m of modelsFor(agent)) select.appendChild(el('option', { value: m.id }, [m.label || m.id]))
141
+ // A model the catalog does not list is still the model the card will be
142
+ // posted with: agy's and grok's lists are cached for an hour and are
143
+ // empty on a fresh board or after a failed probe. Dropping the select to
144
+ // '' there showed "<agent> default" over a card that ran a named model,
145
+ // and silently, so nothing changed the row it was read from. The option
146
+ // is added instead, so the select names what will run and the reader can
147
+ // still move off it.
148
+ if (model && !modelsFor(agent).some((m) => m.id === model)) select.appendChild(el('option', { value: model }, [model]))
149
+ select.value = model || ''
150
+ }
151
+ fill()
152
+ select.addEventListener('change', () => onPick(select.value))
153
+ select.refill = fill
154
+ return select
155
+ }
156
+
157
+ // Exactly what Start posts: one chain entry per rung, carrying that rung's
158
+ // model, from the rung the reader picked downward. De-duplicated by
159
+ // (adapter, model) and not by adapter, because `claude/fable` then
160
+ // `claude/opus` are two real legs and a hand-off between them is the whole
161
+ // point; two rungs that name the same adapter AND the same model are one leg
162
+ // twice, and a hand-off from a leg to its own twin buys nothing.
163
+ function entryChain() {
164
+ const seen = new Set()
165
+ const out = []
166
+ for (const r of ladderRungs().slice(entryState.ladderStart || 0)) {
167
+ const key = `${r.agent}/${r.model || ''}`
168
+ if (seen.has(key)) continue
169
+ seen.add(key)
170
+ out.push(r)
171
+ }
172
+ // A model picked on the row itself applies to the leg that actually starts,
173
+ // which is the first one; the fallbacks under it keep the models the ladder
174
+ // gave them. The override can collide with the rung below it (picking opus
175
+ // on a row whose ladder already reads fable then opus), and two identical
176
+ // legs are one leg twice, so the duplicate goes.
177
+ if (out.length && entryState.model !== undefined) {
178
+ out[0] = { ...out[0], model: entryState.model || null }
179
+ const same = (a, b) => a.agent === b.agent && (a.model || '') === (b.model || '')
180
+ if (out.length > 1 && same(out[0], out[1])) out.splice(1, 1)
181
+ }
182
+ return out
183
+ }
184
+
185
+ // The branch a card in this repo should be cut from. The sessions payload
186
+ // carries the repo's own default branch (the server reads origin/HEAD, then
187
+ // main/master/trunk, then the current branch) and each terminal's branch, so
188
+ // neither the sentence nor the body has to assume `main` in a repo whose
189
+ // default is `master` or `develop`: Start could only ever fail there.
190
+ function entryTrunk(repo) {
191
+ if (!repo) return null
192
+ const known = (host.repoTrunks || []).find((t) => t.repo === repo.path || t.repo_name === repo.name)
193
+ if (known && known.branch) return known.branch
194
+ for (const s of host.sessions || []) {
195
+ if (s.repo !== repo.path) continue
196
+ const b = (s.worktree && s.worktree.base) || (!s.worktree && s.branch)
197
+ if (b) return b
198
+ }
199
+ return null
200
+ }
201
+
202
+ // The body, built once and posted by Start on either page: two pages that
203
+ // build it two ways is two different cards from one sentence.
204
+ function entryBody() {
205
+ const repo = entryRepo()
206
+ const task = (entryState.task || '').trim()
207
+ if (!task || !repo) return null
208
+ const trunk = entryTrunk(repo)
209
+ return {
210
+ repo: repo.path, task,
211
+ chain: entryChain().map((r) => ({ adapter: r.agent, ...(r.model ? { model: r.model } : {}) })),
212
+ ...(trunk ? { trunk } : {}),
213
+ pipeline: entryState.pipeline, queue: true,
214
+ }
215
+ }
216
+
217
+ async function submitEntry() {
218
+ const body = entryBody()
219
+ if (!body) return
220
+ try {
221
+ const data = await api('/api/cards', { method: 'POST', body })
222
+ entryState.task = ''
223
+ entryState.editing = null
224
+ onCreated(data.card)
225
+ } catch (err) { toast(err.message) }
226
+ }
227
+
228
+ function nounSelect(options, current, onPick) {
229
+ const select = el('select', { 'aria-label': 'Change' })
230
+ for (const o of options) select.appendChild(el('option', { value: o.value }, [o.label]))
231
+ select.value = current
232
+ select.addEventListener('change', () => { onPick(select.value); entryState.editing = null; renderEntryLine() })
233
+ return select
234
+ }
235
+
236
+ function nounButton(text, key) {
237
+ return el('button', { type: 'button', class: 'btn btn-text', onclick: () => { entryState.editing = key; renderEntryLine() } }, [text])
238
+ }
239
+
240
+ function renderEntryLine() {
241
+ const box = document.getElementById(boxId)
242
+ if (!box) return
243
+ if (isGuest()) { box.hidden = true; return }
244
+ box.hidden = false
245
+ box.textContent = ''
246
+ const repo = entryRepo()
247
+ const task = el('input', { type: 'text', placeholder: 'Describe the task', 'aria-label': 'Task to run in the background', value: entryState.task })
248
+ // Start has two reasons to be off and says which one it is. A card with no
249
+ // agent to run it is refused by /api/cards with "missing chain", and a
250
+ // button that posts a request it knows will fail is a worse button.
251
+ const canRun = entryChain().length > 0
252
+ const why = !canRun ? `No agent to run it: ${ladderSentence([])}.` : 'Describe the task to start it.'
253
+ const reason = el('span', { class: 'field-help' }, [why])
254
+ reason.hidden = canRun && Boolean(entryState.task.trim())
255
+ const start = el('button', { type: 'button', class: 'btn btn-primary', disabled: canRun && entryState.task.trim() ? null : '' }, ['Start'])
256
+ task.addEventListener('input', () => { entryState.task = task.value; start.disabled = !canRun || !task.value.trim(); reason.hidden = canRun && Boolean(task.value.trim()) })
257
+ // Enter in the field is the same press as Start: a one-line form that can
258
+ // only be submitted by reaching for the mouse is not a one-line form.
259
+ task.addEventListener('keydown', (e) => { if (e.key === 'Enter' && canRun && task.value.trim()) submitEntry() })
260
+ start.addEventListener('click', () => submitEntry())
261
+ // `.confirm-row` is the board's existing sentence-plus-controls strip: a
262
+ // flex row with a gap on the raised surface, which is exactly this line.
263
+ box.appendChild(el('div', { class: 'confirm-row' }, [el('span', {}, ['Run in the background:']), task, start, reason]))
264
+
265
+ const line = el('p', { class: 'field-help' })
266
+ line.appendChild(document.createTextNode('in '))
267
+ if (entryState.editing === 'repo') {
268
+ line.appendChild(nounSelect(knownRepos().map((r) => ({ value: r.path, label: r.name })), repo ? repo.path : '', (v) => { entryState.repo = knownRepos().find((r) => r.path === v) || null }))
269
+ } else {
270
+ line.appendChild(nounButton(repo ? repo.name : 'no repo', 'repo'))
271
+ }
272
+ line.appendChild(document.createTextNode(` on ${entryTrunk(repo) || 'main'}, with `))
273
+ const rungs = ladderRungs()
274
+ if (entryState.editing === 'ladder') {
275
+ // Two selects, not one. The provider select picks the rung the chain
276
+ // starts at; the model select beside it swaps the model on that first leg
277
+ // without touching the saved ladder, which is what "codex, but on
278
+ // gpt-5.6-luna, just this once" means. Opening the pair is one press and
279
+ // changing the model is the next, so the model a reader wants is two
280
+ // presses from the row it is read on. The whole ladder is still edited in
281
+ // Settings, and More settings below writes it back.
282
+ const from = Math.min(entryState.ladderStart || 0, Math.max(0, rungs.length - 1))
283
+ const chosen = rungs[from] || null
284
+ // the pair stays open when the provider changes: closing it would put the
285
+ // model select the reader is reaching for back behind another press
286
+ const who = el('select', { 'aria-label': 'Which agent starts' })
287
+ for (let i = 0; i < rungs.length; i++) who.appendChild(el('option', { value: String(i) }, [ladderLabel(rungs[i])]))
288
+ who.value = String(from)
289
+ who.addEventListener('change', () => { entryState.ladderStart = Number(who.value); entryState.model = undefined; renderEntryLine() })
290
+ line.appendChild(who)
291
+ if (chosen) {
292
+ const picked = entryState.model === undefined ? (chosen.model || '') : entryState.model
293
+ line.appendChild(document.createTextNode(' on '))
294
+ line.appendChild(modelSelect(chosen.agent, picked, `Model for ${chosen.agent}`, (v) => { entryState.model = v; renderEntryLine() }))
295
+ }
296
+ } else {
297
+ // the sentence names the legs Start posts, models and all
298
+ line.appendChild(nounButton(ladderSentence(entryChain()), 'ladder'))
299
+ }
300
+ line.appendChild(document.createTextNode(', '))
301
+ if (entryState.editing === 'pipeline') {
302
+ line.appendChild(nounSelect(Object.keys(PIPELINE_WORDS).map((v) => ({ value: v, label: PIPELINE_WORDS[v] })), entryState.pipeline, (v) => { entryState.pipeline = v }))
303
+ } else {
304
+ line.appendChild(nounButton(PIPELINE_WORDS[entryState.pipeline] || 'build only', 'pipeline'))
305
+ }
306
+ line.appendChild(document.createTextNode('. '))
307
+ line.appendChild(el('button', { type: 'button', class: 'btn btn-text', onclick: () => onMoreSettings(entryState) }, ['More settings']))
308
+ box.appendChild(line)
309
+ }
310
+
311
+ // C.2: always visible under the Background panel; when there are no live
312
+ // cards the panel is hidden, so the entry moves to sit under Terminals
313
+ // instead. The fake DOM in the tests has no after(), so this is a no-op
314
+ // there and a real move in the browser. A page with no Background panel
315
+ // (the floor) passes no anchor and the row never moves.
316
+ function placeEntryLine(hasLive) {
317
+ const anchor = deps.moveUnder
318
+ if (!anchor) return
319
+ const box = document.getElementById(boxId)
320
+ if (!box) return
321
+ if (hasLive) {
322
+ const bg = document.getElementById(anchor.whenLive)
323
+ if (bg && typeof bg.appendChild === 'function') bg.appendChild(box)
324
+ } else {
325
+ const terms = document.querySelector(anchor.whenEmpty)
326
+ if (terms && typeof terms.after === 'function') terms.after(box)
327
+ }
328
+ }
329
+
330
+ return {
331
+ entryState, PIPELINE_WORDS, knownRepos, inferRepo, entryRepo, realAdapters, ladderAgents, asRung,
332
+ ladderFromPreferences, ladderRungs, ladderLabel, ladderSentence,
333
+ modelsFor, defaultModelLabel, modelSelect,
334
+ entryChain, entryTrunk, entryBody, submitEntry, nounSelect, nounButton,
335
+ renderEntryLine, placeEntryLine,
336
+ }
337
+ }
338
+
339
+ if (typeof window !== 'undefined') window.legEntry = { create }
340
+ // test seam: node:test runs this file with a stub document, the way the other
341
+ // board scripts are run; in a browser there is no `module`
342
+ if (typeof module !== 'undefined') module.exports = { create }
343
+ })()
@@ -21,13 +21,9 @@
21
21
  <div class="sched-status" id="sched-status"></div>
22
22
  </div>
23
23
  <div class="masthead-right">
24
+ <!-- the counts used to be here as well; every one of them is now the count
25
+ beside its own station heading, where the rows it counts are. -->
24
26
  <div class="repos-status">repos: <span id="repos-list">-</span></div>
25
- <div class="counts-status">
26
- running <span id="count-running">0</span>
27
- queued <span id="count-queued">0</span>
28
- waiting <span id="count-waiting">0</span>
29
- done <span id="count-done">0</span>
30
- </div>
31
27
  <a class="floor-link" href="/">Board</a>
32
28
  </div>
33
29
  </header>
@@ -40,53 +36,66 @@
40
36
 
41
37
  <main class="wrap">
42
38
 
43
- <section class="logins" id="floor-accounts" aria-label="Logins"></section>
44
- <p class="head-caption">Times are local.</p>
39
+ <!-- The same capacity strip the board draws, from the same accounts payload
40
+ and the same file (strip.js). The four login panels are behind the same
41
+ disclosure they are behind on the board: on the floor they used to eat
42
+ the whole first screen, above every row the page exists to show. -->
43
+ <section class="capacity" aria-label="Capacity by login">
44
+ <div class="capacity-strip">
45
+ <div class="cap-tokens" id="capacity-tokens"></div>
46
+ <button type="button" class="btn btn-secondary disclosure cap-open" id="capacity-toggle" aria-expanded="false" aria-controls="capacity-drawer">Capacity and models &gt;</button>
47
+ </div>
48
+ <div class="drawer capacity-drawer" id="capacity-drawer" hidden>
49
+ <section class="logins" id="floor-accounts" aria-label="Logins"></section>
50
+ <p class="head-caption">Times are local.</p>
51
+ </div>
52
+ </section>
45
53
 
46
- <section class="region-floor" aria-labelledby="running-head">
54
+ <!-- entry.js fills this, the same row the board carries: one sentence, one
55
+ Start, and the same body posted to /api/cards. -->
56
+ <div class="floor-entry" id="card-entry" hidden></div>
57
+
58
+ <section class="region-floor" id="station-running" aria-labelledby="running-head">
47
59
  <div class="section-head">
48
- <h2 id="running-head">Running</h2>
60
+ <h2 id="running-head">Running <span class="region-count" id="count-running">0</span></h2>
49
61
  <p class="region-meta"></p>
50
62
  </div>
51
- <table class="ftable">
52
- <colgroup><col class="c-card"><col class="c-station"><col class="c-agent"><col class="c-leases"><col class="c-event"><col class="c-elapsed"><col class="c-actions"></colgroup>
53
- <thead><tr>
54
- <th scope="col">Card</th><th scope="col">Station</th><th scope="col">Agent / leg</th>
55
- <th scope="col">Leases</th><th scope="col">Last event</th><th scope="col">Elapsed</th>
56
- <th scope="col">Actions</th>
57
- </tr></thead>
58
- <tbody id="running-body"></tbody>
59
- </table>
63
+ <div class="card-list" id="running-rows"></div>
60
64
  </section>
61
65
 
62
- <section class="region-floor" aria-labelledby="waiting-head">
66
+ <section class="region-floor" id="station-waiting" aria-labelledby="waiting-head">
63
67
  <div class="section-head">
64
- <h2 id="waiting-head">Waiting on humans</h2>
68
+ <h2 id="waiting-head">Waiting on you <span class="region-count" id="count-waiting">0</span></h2>
65
69
  <p class="region-meta"></p>
66
70
  </div>
67
- <table class="ftable">
68
- <colgroup><col class="c-card"><col class="c-station"><col class="c-status"><col class="c-since"><col class="c-actions"></colgroup>
69
- <thead><tr>
70
- <th scope="col">Card</th><th scope="col">Station</th><th scope="col">Status</th>
71
- <th scope="col">Since</th><th scope="col">Actions</th>
72
- </tr></thead>
73
- <tbody id="waiting-body"></tbody>
74
- </table>
71
+ <div class="card-list" id="waiting-rows"></div>
75
72
  </section>
76
73
 
77
- <section class="region-floor" aria-labelledby="queued-head">
74
+ <section class="region-floor" id="station-queued" aria-labelledby="queued-head">
78
75
  <div class="section-head">
79
- <h2 id="queued-head">Queued</h2>
76
+ <h2 id="queued-head">Queued <span class="region-count" id="count-queued">0</span></h2>
80
77
  <p class="region-meta"></p>
81
78
  </div>
82
- <table class="ftable">
83
- <colgroup><col class="c-card"><col class="c-station"><col class="c-leases"><col class="c-blocked"></colgroup>
84
- <thead><tr>
85
- <th scope="col">Card</th><th scope="col">Station</th><th scope="col">Leases</th>
86
- <th scope="col">Blocked by</th>
87
- </tr></thead>
88
- <tbody id="queued-body"></tbody>
89
- </table>
79
+ <div class="card-list" id="queued-rows"></div>
80
+ </section>
81
+
82
+ <section class="region-floor" id="station-backlog" aria-labelledby="backlog-head">
83
+ <div class="section-head">
84
+ <h2 id="backlog-head">Backlog <span class="region-count" id="count-backlog">0</span></h2>
85
+ <p class="region-meta"></p>
86
+ </div>
87
+ <div class="card-list" id="backlog-rows"></div>
88
+ </section>
89
+
90
+ <!-- what is finished is history: one line per card, behind a disclosure, the
91
+ same shape the board's finished ledger takes -->
92
+ <section class="region-floor" id="station-done" aria-labelledby="done-head">
93
+ <div class="section-head section-head--quiet">
94
+ <h2 id="done-head">Done today <span class="region-count" id="count-done">0</span></h2>
95
+ <p class="region-meta"></p>
96
+ <button type="button" class="btn btn-secondary disclosure" id="done-toggle" aria-expanded="false" aria-controls="done-rows">View</button>
97
+ </div>
98
+ <div class="card-list" id="done-rows" hidden></div>
90
99
  </section>
91
100
 
92
101
  <section class="region-floor" aria-labelledby="leases-head">
@@ -122,6 +131,9 @@
122
131
 
123
132
  <div class="sysmsg" id="toast" aria-live="polite"></div>
124
133
 
134
+ <!-- shared with the board, and read at load by floor.js under them -->
135
+ <script src="strip.js" defer></script>
136
+ <script src="entry.js" defer></script>
125
137
  <script src="floor.js" defer></script>
126
138
  </body>
127
139
  </html>