@ucsandman/legcli 0.10.0 → 0.12.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 (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
package/src/audit.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // audit — one list of who did what on this board, across every terminal and
2
+ // every card, newest first.
3
+ //
4
+ // The ledger already names an actor on every event; until now you could only
5
+ // read that one session or one card at a time, which is no use when the
6
+ // question is "who landed that?" or "who handed my terminal off last night".
7
+ // Nothing new is recorded here: this reads what is already on disk.
8
+ //
9
+ // Owner only (src/server.mjs): the trail names repositories and people.
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { home } from './store.mjs'
13
+ import { listSessions, readEvents as readSessionEvents } from './sessions.mjs'
14
+ import { readEvents as readCardEvents } from './ledger.mjs'
15
+
16
+ // The types worth a line in an audit: something a person or an agent DID, not
17
+ // the running commentary. A `status` line is commentary; a hand-off is not.
18
+ export const AUDITED = [
19
+ 'handoff', 'handoff_requested', 'handed_off', 'landed', 'land', 'bounced', 'killed',
20
+ 'approved', 'approval_needed', 'reassigned', 'paused', 'resumed', 'taken_over', 'ended', 'done',
21
+ 'failed', 'trust', 'harness', 'worktree', 'station_done', 'leg_started', 'rerun',
22
+ ]
23
+
24
+ export const ACTOR_KINDS = ['human', 'agent', 'leg']
25
+
26
+ function actorOf(ev) {
27
+ // a card event carries a structured actor; a session event carries `by`
28
+ if (ev.actor && typeof ev.actor === 'object') {
29
+ if (ev.actor.type === 'human') return { kind: 'human', name: String(ev.actor.id ?? 'unknown') }
30
+ if (ev.actor.type === 'agent') return { kind: 'agent', name: String(ev.actor.adapter ?? 'agent') }
31
+ return { kind: 'leg', name: 'leg' }
32
+ }
33
+ if (ev.by) return { kind: 'human', name: String(ev.by) }
34
+ return { kind: 'leg', name: 'leg' }
35
+ }
36
+
37
+ function cardIds() {
38
+ const root = join(home(), 'cards')
39
+ if (!existsSync(root)) return []
40
+ try { return readdirSync(root).filter((d) => existsSync(join(root, d, 'card.json'))) } catch { return [] }
41
+ }
42
+
43
+ function cardMeta(id) {
44
+ try { return JSON.parse(readFileSync(join(home(), 'cards', id, 'card.json'), 'utf8')) } catch { return null }
45
+ }
46
+
47
+ // → { entries: [...], truncated, scanned: { sessions, cards, events } }
48
+ // `scanned` is on the record deliberately: an empty audit from a board that
49
+ // looked at nothing reads exactly like a quiet week, and the two are not the
50
+ // same thing.
51
+ export function auditTrail({ limit = 200, since = null, who = null, kind = null, types = null } = {}) {
52
+ const sinceMs = since ? Date.parse(since) : null
53
+ const wanted = Array.isArray(types) && types.length ? new Set(types) : new Set(AUDITED)
54
+ const rows = []
55
+ let events = 0
56
+
57
+ const sessions = listSessions()
58
+ for (const s of sessions) {
59
+ for (const ev of readSessionEvents(s.session_id)) {
60
+ events++
61
+ if (!wanted.has(ev.type)) continue
62
+ const at = Date.parse(ev.ts)
63
+ if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
64
+ const actor = actorOf(ev)
65
+ rows.push({
66
+ at: ev.ts,
67
+ who: actor.name,
68
+ kind: actor.kind,
69
+ what: ev.type,
70
+ summary: String(ev.summary ?? ''),
71
+ where: 'terminal',
72
+ id: s.session_id,
73
+ agent: s.agent ?? null,
74
+ repo: s.repo ?? null,
75
+ branch: s.branch ?? null,
76
+ })
77
+ }
78
+ }
79
+
80
+ const ids = cardIds()
81
+ for (const id of ids) {
82
+ const card = cardMeta(id)
83
+ for (const ev of readCardEvents(id)) {
84
+ events++
85
+ if (!wanted.has(ev.type)) continue
86
+ const at = Date.parse(ev.ts)
87
+ if (sinceMs && Number.isFinite(at) && at < sinceMs) continue
88
+ const actor = actorOf(ev)
89
+ rows.push({
90
+ at: ev.ts,
91
+ who: actor.name,
92
+ kind: actor.kind,
93
+ what: ev.type,
94
+ summary: String(ev.summary ?? ''),
95
+ where: 'card',
96
+ id,
97
+ agent: ev.actor?.type === 'agent' ? ev.actor.adapter : null,
98
+ repo: card?.repo ?? null,
99
+ branch: card?.branch ?? null,
100
+ })
101
+ }
102
+ }
103
+
104
+ let filtered = rows
105
+ if (who) filtered = filtered.filter((r) => r.who.toLowerCase() === String(who).toLowerCase())
106
+ if (kind) filtered = filtered.filter((r) => r.kind === kind)
107
+ filtered.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0))
108
+ const capped = filtered.slice(0, Math.max(1, Math.min(limit, 1000)))
109
+
110
+ return {
111
+ entries: capped,
112
+ truncated: filtered.length > capped.length,
113
+ matched: filtered.length,
114
+ // L2: a verdict carries the volume it processed
115
+ scanned: { sessions: sessions.length, cards: ids.length, events },
116
+ people: [...new Set(rows.filter((r) => r.kind === 'human').map((r) => r.who))].sort(),
117
+ }
118
+ }
@@ -0,0 +1,123 @@
1
+ // Audit trail: who did what, across every terminal and every card on this
2
+ // board. Data: /api/audit, which is owner-only — the trail names repositories
3
+ // and the people on the roster.
4
+ //
5
+ // It loads when the reader asks, never on a board render: the trail reads every
6
+ // session's events and every card's ledger, and that is not work to do on a
7
+ // timer behind a panel nobody has opened. Like the history cell, nothing here
8
+ // arrives over SSE (DESIGN.md rule 3: the live terminals lane is the only live
9
+ // region).
10
+ //
11
+ // `el`, `api`, `getToken`, `ago` and `whenAgo` are copied from sessions.js,
12
+ // which cannot export from its IIFE. The time grammar is sessions.js's: a
13
+ // change to ago() there is a change here in the same commit.
14
+ (function () {
15
+ 'use strict'
16
+
17
+ function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
18
+ async function api(path, opts = {}) {
19
+ const headers = { 'Content-Type': 'application/json' }
20
+ const token = getToken()
21
+ if (token) headers.Authorization = `Bearer ${token}`
22
+ const r = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined })
23
+ const data = await r.json().catch(() => ({}))
24
+ if (!r.ok) throw new Error(data.error || `${r.status} ${r.statusText}`)
25
+ return data
26
+ }
27
+ function el(tag, attrs, children) {
28
+ const n = document.createElement(tag)
29
+ for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined) continue; if (k === 'class') n.className = v; else n.setAttribute(k, v) }
30
+ for (const c of children || []) { if (c === null || c === undefined) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c) }
31
+ return n
32
+ }
33
+ function ago(ms) {
34
+ const s = Math.max(0, Math.floor(ms / 1000))
35
+ if (s < 60) return `${s}s`
36
+ const m = Math.floor(s / 60)
37
+ if (m < 60) return `${m}m`
38
+ const h = Math.floor(m / 60)
39
+ if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ''}`
40
+ const d = Math.floor(h / 24)
41
+ return `${d}d${h % 24 ? ` ${h % 24}h` : ''}`
42
+ }
43
+ function whenAgo(ts) {
44
+ const t = Date.parse(ts)
45
+ return Number.isFinite(t) ? `${ago(Date.now() - t)} ago` : ''
46
+ }
47
+ function clockAt(ms) {
48
+ const d = new Date(ms)
49
+ if (!Number.isFinite(d.getTime())) return ''
50
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })
51
+ }
52
+
53
+ const root = document.getElementById('audit')
54
+ if (!root) return
55
+ const whoSel = document.getElementById('audit-who')
56
+ const kindSel = document.getElementById('audit-kind')
57
+ const loadBtn = document.getElementById('audit-load')
58
+ const meta = document.getElementById('audit-meta')
59
+ const list = document.getElementById('audit-list')
60
+ let knownPeople = null
61
+
62
+ // A guest or an operator never sees this panel; the fieldset stays hidden
63
+ // until /api/audit answers, so a 403 leaves nothing on the page to click.
64
+ async function reveal() {
65
+ try {
66
+ const r = await api('/api/audit?limit=1')
67
+ root.hidden = false
68
+ knownPeople = r.people || []
69
+ fillPeople()
70
+ } catch { root.hidden = true }
71
+ }
72
+
73
+ function fillPeople() {
74
+ if (!knownPeople) return
75
+ const current = whoSel.value
76
+ while (whoSel.options.length > 1) whoSel.remove(1)
77
+ for (const p of knownPeople) whoSel.appendChild(el('option', { value: p }, [p]))
78
+ whoSel.value = current
79
+ }
80
+
81
+ function row(e) {
82
+ const line = el('div', { class: 'audit-row' })
83
+ line.appendChild(el('span', { class: 'audit-when', title: e.at }, [clockAt(Date.parse(e.at))]))
84
+ line.appendChild(el('span', { class: `chip chip-id-${e.kind === 'human' ? 'human' : (e.agent || 'leg')}` }, [e.who]))
85
+ line.appendChild(el('span', { class: 'audit-what' }, [e.what.replace(/_/g, ' ')]))
86
+ line.appendChild(el('span', { class: 'audit-summary' }, [e.summary || '']))
87
+ const where = e.where === 'card' ? `card ${e.id}` : `terminal ${String(e.id).split('-').pop()}`
88
+ line.appendChild(el('span', { class: 'chip' }, [where]))
89
+ if (e.repo) line.appendChild(el('span', { class: 'chip' }, [String(e.repo).split(/[\\/]/).pop() + (e.branch ? `@${e.branch}` : '')]))
90
+ line.appendChild(el('span', { class: 'audit-ago' }, [whenAgo(e.at)]))
91
+ return line
92
+ }
93
+
94
+ async function load() {
95
+ loadBtn.disabled = true
96
+ meta.textContent = 'reading every terminal and every card…'
97
+ list.replaceChildren()
98
+ try {
99
+ const q = new URLSearchParams({ limit: '200' })
100
+ if (whoSel.value) q.set('who', whoSel.value)
101
+ if (kindSel.value) q.set('kind', kindSel.value)
102
+ const r = await api(`/api/audit?${q}`)
103
+ knownPeople = r.people || knownPeople
104
+ fillPeople()
105
+ // L2: the verdict carries the volume it processed, so an empty trail from
106
+ // a board that looked at nothing does not read like a quiet week.
107
+ const scanned = `${r.scanned.sessions} terminal${r.scanned.sessions === 1 ? '' : 's'} and ${r.scanned.cards} card${r.scanned.cards === 1 ? '' : 's'}, ${r.scanned.events} events read`
108
+ meta.textContent = r.entries.length
109
+ ? `${r.matched} action${r.matched === 1 ? '' : 's'}${r.truncated ? `, newest ${r.entries.length} shown` : ''} — ${scanned}`
110
+ : `nothing matched — ${scanned}`
111
+ for (const e of r.entries) list.appendChild(row(e))
112
+ } catch (err) {
113
+ meta.textContent = err.message
114
+ } finally {
115
+ loadBtn.disabled = false
116
+ }
117
+ }
118
+
119
+ loadBtn.addEventListener('click', load)
120
+ whoSel.addEventListener('change', load)
121
+ kindSel.addEventListener('change', load)
122
+ reveal()
123
+ })()
@@ -205,11 +205,56 @@ code { font-family: var(--mono); font-size: 0.94em; }
205
205
  .sched-status { color: var(--text-3); }
206
206
 
207
207
  /* ---- the verdict: the largest thing on the board is a sentence ---------- */
208
- .verdict { padding-block: 56px 56px; }
208
+ /* 40/24 rather than 56/56, and a 17px sub rather than 21px: the verdict stays
209
+ the largest thing on the page and costs about 180px instead of 335, which is
210
+ what put the first terminal row a thousand pixels down. The 26ch measure is
211
+ the character budget's other half: 300 sampled sentences per length still
212
+ fit two 56.16px lines at 60 characters there, and sessions.js holds every
213
+ branch at 56. */
214
+ .verdict { padding-block: 40px 24px; }
209
215
  .verdict h1 { font-size: var(--t-4); font-weight: var(--w-head); line-height: 1.08; letter-spacing: -.028em; max-width: 26ch; text-wrap: balance; }
210
- .verdict p { margin-top: 16px; font-size: var(--t-2); line-height: 1.5; color: var(--text-2); max-width: 54ch; text-wrap: pretty; }
216
+ .verdict p { margin-top: 8px; font-size: var(--t-1); line-height: 1.5; color: var(--text-2); max-width: 54ch; text-wrap: pretty; }
211
217
  .verdict p:empty { display: none; }
212
218
 
219
+ /* ---- the capacity strip: one band, one token per login ------------------ */
220
+ /* The binding bucket and nothing else. The instrument is 120x6: enough to see
221
+ a login crossing its reserve at a glance, too small to compete with the
222
+ sentence above it. */
223
+ .capacity { margin-bottom: 4px; }
224
+ /* the strip itself never wraps: the disclosure keeps the strip's own line at
225
+ the right (A.2) and the tokens wrap inside their own box, so four logins
226
+ that need two rows of tokens cost no third line for one button. */
227
+ .capacity-strip { display: flex; align-items: center; gap: 12px 32px; flex-wrap: nowrap; min-height: 44px; font-size: var(--t-0); }
228
+ .cap-tokens { display: flex; align-items: center; gap: 12px 32px; flex-wrap: wrap; flex: 1 1 auto; min-width: 0; }
229
+ .cap-token { display: inline-flex; align-items: center; gap: 10px; min-height: 24px; }
230
+ .cap-name { font-weight: var(--w-head); color: var(--text-2); }
231
+ .cap-name.id-claude { color: var(--id-claude); }
232
+ .cap-name.id-codex { color: var(--id-codex); }
233
+ .cap-name.id-agy { color: var(--id-agy); }
234
+ .cap-name.id-grok { color: var(--id-grok); }
235
+ .cap-name.id-copilot { color: var(--id-copilot); }
236
+ .cap-name.id-fake { color: var(--id-fake); }
237
+ .cap-track { position: relative; flex: none; width: 120px; height: 6px; background: var(--track); border-radius: 999px; box-shadow: var(--sink); overflow: hidden; }
238
+ .cap-fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: var(--calm); }
239
+ .cap-figure { color: var(--text-2); white-space: nowrap; }
240
+ /* a login at its wall is the one place a word IS the failure, so it may carry
241
+ the colour; a percentage never does, the track carries that */
242
+ .cap-figure.is-out { color: var(--danger-text); }
243
+ .cap-figure--none { color: var(--text-3); }
244
+ .cap-open { margin-left: auto; flex: none; }
245
+ .capacity-drawer { margin-top: 16px; }
246
+
247
+ /* the model rail on a panel head inside the drawer: a measured percentage, or
248
+ a wall in words, never one printed as the other */
249
+ .model-rail { display: inline-flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
250
+ .model-chip { white-space: nowrap; }
251
+ .model-chip.is-out { color: var(--danger-text); }
252
+ /* an open model this login has a live terminal on is a control: pressing it
253
+ puts that rung at the top of the terminal's ladder. The one the terminal is
254
+ already on, and a walled one, stay text: there is nothing to press. */
255
+ .model-chip--pick { font-size: var(--t--1); min-height: 28px; }
256
+ .model-chip.is-current { color: var(--text-2); }
257
+
213
258
  /* ---- panel: a raised object, not a drawn rectangle ---------------------- */
214
259
  .panel { position: relative; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-panel); padding: 32px; box-shadow: var(--lift); }
215
260
  .panel--lit { box-shadow: var(--lift-hi); }
@@ -252,7 +297,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
252
297
  .reading-sub--lead { margin-top: 22px; }
253
298
 
254
299
  /* ---- section heads ------------------------------------------------------ */
255
- .section-head { padding-block: 56px 20px; }
300
+ .section-head { padding-block: 32px 16px; }
256
301
  .section-head h2 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
257
302
  .section-head .region-meta { margin-top: 8px; font-size: var(--t-0); color: var(--text-3); }
258
303
  .section-head--quiet { padding-block: 56px 16px; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
@@ -274,6 +319,28 @@ code { font-family: var(--mono); font-size: 0.94em; }
274
319
  .term-clock { flex: none; display: flex; align-items: baseline; justify-content: flex-end; gap: 14px; font-size: var(--t--1); color: var(--text-3); white-space: nowrap; }
275
320
  .term-register { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
276
321
  .term-where { color: var(--text-2); }
322
+ /* the register's data tokens (A.4 rows 7 to 9). They are one line with the
323
+ status word, in the register's own muted tone: what changed and how long it
324
+ has been quiet are observations, not demands. The model token is the one
325
+ fact here a reader looks for by name, so it takes the identity colour and
326
+ the mono face the other machine strings on this page use. */
327
+ .term-dirty, .term-ahead, .term-quiet { color: var(--text-3); }
328
+ .term-model { font-family: var(--mono); }
329
+ /* the files line and the row's capacity phrase share one line, the phrase at
330
+ the right (A.2). The phrase is per MODEL, which is a fact about this row;
331
+ the region head carries the share clause for the login. */
332
+ .term-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 24px; flex-wrap: wrap; }
333
+ .term-meta .files { flex: 1 1 24ch; min-width: 0; }
334
+ /* the phrase wraps rather than pushing the row sideways: with a forecast it is
335
+ `about 2h 40m of fable left, from 9 samples over 4h`, two and a half times
336
+ the percentage form, and a nowrap run that long overflows the 400px board. */
337
+ .term-capacity { margin-top: 10px; flex: none; display: inline-flex; align-items: baseline; gap: 10px; font-size: var(--t--1); color: var(--text-3); }
338
+ /* the climb is a link beside the figure that explains it, never a fifth button
339
+ in the 2x2 grid, which is the shipped shape */
340
+ .term-climb { font-size: var(--t--1); }
341
+ /* the keyboard ring: a row the keys act on, marked on the edge the eye scans
342
+ down rather than by a fill that would compete with is-urgent */
343
+ .term.is-focused { box-shadow: inset 3px 0 0 var(--focus); }
277
344
  .term-when { color: var(--text-3); min-width: 6ch; text-align: right; }
278
345
  .term-id { color: var(--text-3); min-width: 4ch; text-align: right; font-family: var(--mono); }
279
346
  .term-actions { flex: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; width: 272px; }
@@ -391,6 +458,21 @@ code { font-family: var(--mono); font-size: 0.94em; }
391
458
  .history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
392
459
  .history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
393
460
  .history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
461
+ /* Audit trail (Settings). Same grammar as the history rows: a flat list with
462
+ hairline separators, the time on the left, the actor as a chip. */
463
+ .audit { border: 0; padding: 0; margin: 22px 0 0; }
464
+ .audit legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
465
+ .audit-filters { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin: 10px 0 14px; }
466
+ .audit-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
467
+ .audit-list { display: block; }
468
+ .audit-row { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: baseline; padding: 10px 0; font-size: var(--t-0); }
469
+ .audit-row + .audit-row { border-top: 1px solid var(--line); }
470
+ .audit-when { color: var(--text-3); font-variant-numeric: tabular-nums; min-width: 12ch; }
471
+ .audit-what { font-weight: var(--w-head); }
472
+ .audit-summary { color: var(--text-2); flex: 1 1 46ch; min-width: 0; overflow-wrap: anywhere; }
473
+ .audit-ago { color: var(--text-3); margin-left: auto; }
474
+ @media (max-width: 640px) { .audit-ago { margin-left: 0; } }
475
+
394
476
  .history-list { display: block; }
395
477
  .history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
396
478
  .history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
@@ -487,8 +569,26 @@ code { font-family: var(--mono); font-size: 0.94em; }
487
569
  /* ---- chain rail, order rows, confirm ------------------------------------ */
488
570
  .chain-rail { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
489
571
  .cap-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; font-size: var(--t-0); color: var(--text-3); margin-top: 8px; }
490
- .order-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; font-size: var(--t-0); }
491
- .order-row-name { flex: 1 1 auto; }
572
+ /* the ladder editor: one rung per row, numbered, with its when, its cost word
573
+ and three buttons. The name takes the free space so every row's controls line
574
+ up in one column whatever the rung is called. */
575
+ .ladder-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; font-size: var(--t-0); flex-wrap: wrap; }
576
+ .ladder-num { flex: none; color: var(--text-3); font-variant-numeric: tabular-nums; }
577
+ .ladder-name { flex: 1 1 12ch; min-width: 10ch; }
578
+ .ladder-rule { flex: 0 0 auto; width: 22ch; display: flex; align-items: center; gap: 8px; }
579
+ .ladder-cost { flex: none; font-size: var(--t--1); color: var(--text-3); }
580
+ /* the cost word is a column of its own so the three buttons line up down the
581
+ list whatever a rung costs */
582
+ .ladder-costcol { width: 12ch; }
583
+ .ladder-when { flex: none; min-height: 36px; font-size: var(--t--1); }
584
+ .ladder-pct { flex: none; width: 7ch; min-height: 36px; font-size: var(--t--1); }
585
+ /* a stored 0 or 100 is shown as it is and says what it does: the editor never
586
+ rewrites a number nobody chose. It wraps the rule cell rather than widening
587
+ it, so the columns beside it do not move. */
588
+ .ladder-flag { flex: 1 0 100%; font-size: var(--t--1); }
589
+ .ladder-add { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 0 2px; font-size: var(--t-0); border-top: 1px solid var(--line); }
590
+ .ladder-policy { display: grid; gap: 8px; margin-top: 18px; border: 0; padding: 0; }
591
+ .ladder-policy legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding: 0; }
492
592
  /* the label on each agent row in the New card dialog */
493
593
  .fallback-row-title { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
494
594
  .confirm-row { margin-top: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: var(--e4); border-radius: var(--r-control); padding: 12px 14px; font-size: var(--t-0); }
@@ -522,6 +622,22 @@ input[type="text"], input[type="password"], textarea, select { font: inherit; fo
522
622
  input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; }
523
623
  fieldset { border: 1px solid var(--edge); border-radius: var(--r-well); padding: 18px; display: grid; gap: 12px; }
524
624
  legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding-inline: 6px; }
625
+
626
+ /* the two notification toggles (spec E). A checkbox and its sentence on one
627
+ line, with the help text under it in the field colour the rest of Settings
628
+ uses; a disabled toggle carries its reason in that help text, never in a
629
+ tooltip, because the reason is the whole point of the disabled state. */
630
+ .notify .field-help { margin-top: -4px; }
631
+ .notify-toggle { display: flex; align-items: baseline; gap: 10px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
632
+ .notify-toggle input[disabled] { cursor: not-allowed; }
633
+ .notify-toggle:has(input[disabled]) { color: var(--text-3); }
634
+
635
+ /* the keyboard map: a small panel over the page, opened by ? and closed by ?
636
+ or Escape. Every row names a key and the button that key presses. */
637
+ .keymap { position: fixed; right: 24px; bottom: 24px; z-index: 20; max-width: 380px; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-panel); box-shadow: var(--lift-hi); padding: 22px; }
638
+ .keymap-list { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin-bottom: 18px; font-size: var(--t-0); }
639
+ .keymap-key { font-family: var(--mono); color: var(--text); white-space: nowrap; }
640
+ .keymap-what { color: var(--text-2); }
525
641
  .form-row { display: grid; gap: 8px; margin-top: 18px; }
526
642
  .form-error { background: var(--e1); border-left: 3px solid var(--danger); border-radius: var(--r-control); padding: 12px 14px; font-size: var(--t-0); color: var(--danger-text); }
527
643
  .advanced-options { margin-top: 22px; }
@@ -550,9 +666,18 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
550
666
  =========================================================================== */
551
667
  @media (max-width: 760px) {
552
668
  .wrap { --t-4: 2rem; --t-3: 1.75rem; --t-2: 1.1875rem; --t--1: 0.9375rem; }
553
- .verdict { padding-block: 36px 32px; }
669
+ .verdict { padding-block: 32px 20px; }
554
670
  .verdict h1 { max-width: none; letter-spacing: -.02em; }
555
- .verdict p { margin-top: 16px; }
671
+ .verdict p { margin-top: 12px; }
672
+ /* the tracks go and the tokens stack, one login per 24px line: at this width
673
+ 120px of instrument costs the percentage its own line, and the percentage
674
+ with its state word carries the whole fact on its own */
675
+ .capacity-strip { display: block; }
676
+ .cap-tokens { display: block; }
677
+ .cap-token { display: flex; min-height: 24px; gap: 8px; }
678
+ .cap-track { display: none; }
679
+ .cap-figure { margin-left: auto; }
680
+ .cap-open { margin-left: 0; margin-top: 12px; }
556
681
  .panel { padding: 22px; border-radius: 14px; }
557
682
  .logins-pair { grid-template-columns: 1fr; }
558
683
  .gauge { gap: 14px; flex-wrap: wrap; }
@@ -564,8 +689,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
564
689
  .gauge--noread::after { width: 68px; }
565
690
  .gauge-block + .gauge-block { margin-top: 22px; }
566
691
  code { font-size: 1em; }
567
- .section-head { padding-block: 40px 16px; }
568
- .section-head--quiet { padding-block: 40px 16px; }
692
+ .section-head { padding-block: 24px 16px; }
693
+ .section-head--quiet { padding-block: 24px 16px; }
569
694
  .term { padding: 22px; }
570
695
  .term-row { flex-direction: column; align-items: stretch; gap: 14px; }
571
696
  .term-clock { order: -1; width: auto; justify-content: flex-end; gap: 14px; }