@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
package/src/auth.mjs ADDED
@@ -0,0 +1,85 @@
1
+ // auth — who is allowed in. Loopback with no token is open; any other bind
2
+ // address refuses to start without a token, and with a token every /api
3
+ // request needs `Authorization: Bearer <token>` (timing-safe). With `baton
4
+ // share` on, each human has their own token and the board knows their name
5
+ // and role (src/share.mjs); a loopback request is still the owner, so the
6
+ // machine's own browser needs nothing.
7
+ import { timingSafeEqual } from 'node:crypto'
8
+ import { identify, isOn as shareIsOn, personNamed } from './share.mjs'
9
+
10
+ export const LOOPBACK = ['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']
11
+
12
+ export function isLoopback(bind) {
13
+ return LOOPBACK.includes(String(bind ?? '').trim().toLowerCase().replace(/^\[|\]$/g, ''))
14
+ }
15
+
16
+ // Tokenless owner access is safe only when both ends name loopback. Checking
17
+ // the socket alone lets an attacker-controlled DNS name rebind to 127.0.0.1;
18
+ // its page then has a same-origin Host/Origin pair and can drive the board.
19
+ export function isLoopbackRequest(req) {
20
+ if (!isLoopback(remoteAddress(req))) return false
21
+ const host = req?.headers?.host
22
+ if (typeof host !== 'string' || !host) return false
23
+ try {
24
+ const target = new URL(`http://${host}`)
25
+ return isLoopback(target.hostname)
26
+ } catch { return false }
27
+ }
28
+
29
+ export class BindRefused extends Error {
30
+ constructor(bind) {
31
+ super(`refusing to bind ${bind} without LEG_TOKEN; see README "Network exposure"`)
32
+ this.name = 'BindRefused'
33
+ this.exitCode = 3
34
+ }
35
+ }
36
+
37
+ export function checkBind({ bind, token, share = null }) {
38
+ // share on means every request carries a personal token, so the bind is guarded
39
+ if (!isLoopback(bind) && !token && !shareIsOn(share ?? undefined)) throw new BindRefused(bind)
40
+ return true
41
+ }
42
+
43
+ export function remoteAddress(req) { return req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '' }
44
+
45
+ export function tokenMatches(token, presented) {
46
+ if (!token || typeof presented !== 'string') return false
47
+ const a = Buffer.from(token)
48
+ const b = Buffer.from(presented)
49
+ if (a.length !== b.length) return false
50
+ return timingSafeEqual(a, b)
51
+ }
52
+
53
+ // Bearer header, or ?token= for EventSource (which cannot set headers).
54
+ export function presentedToken(req, url) {
55
+ const h = req.headers.authorization
56
+ if (h && /^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim()
57
+ return url.searchParams.get('token')
58
+ }
59
+
60
+ // → { ok, subject, person } where person is { name, role } when share is on.
61
+ export function authorize({ token, req, url, share = null, bind = null, loopbackOwner = null }) {
62
+ const presented = presentedToken(req, url)
63
+ if (shareIsOn(share ?? undefined)) {
64
+ const person = identify(share, presented)
65
+ if (person) return { ok: true, subject: person.name, person }
66
+ // the machine's own browser is the owner over LOOPBACK only: with a
67
+ // non-loopback (Tailscale/LAN) bind the server also listens on 127.0.0.1
68
+ // (createBoardServer), so the owner opens the loopback URL tokenless while a
69
+ // real remote peer's address is never loopback and always needs a token.
70
+ const localOwner = loopbackOwner === null ? isLoopback(remoteAddress(req)) : loopbackOwner
71
+ if (!presented && share.loopback_owner !== false && localOwner) {
72
+ const owner = personNamed(share, share.owner) ?? share.people.find((p) => p.role === 'owner') ?? null
73
+ if (owner) return { ok: true, subject: owner.name, person: owner }
74
+ }
75
+ return { ok: false, subject: null, person: null }
76
+ }
77
+ // checkBind's invariant, re-applied per request: share.json stops reading as
78
+ // on (`baton share off`, a truncated file) while the shared address is still
79
+ // bound, and nobody but this machine may be the local owner in that window
80
+ if (!token && loopbackOwner === false) return { ok: false, subject: null, person: null }
81
+ if (!token && bind && !isLoopback(bind) && !isLoopback(remoteAddress(req))) return { ok: false, subject: null, person: null }
82
+ if (!token) return { ok: true, subject: 'local', person: null }
83
+ if (tokenMatches(token, presented)) return { ok: true, subject: 'token', person: null }
84
+ return { ok: false, subject: null, person: null }
85
+ }
@@ -0,0 +1,543 @@
1
+ /* Leg board: dark-only operator UI. Shared by index.html and floor.html.
2
+ *
3
+ * The design this implements is .design/BOARD-V4-DIRECTION.md: mock B,
4
+ * "dark product surface", with C's headline framing and A's staleness
5
+ * sentence. It replaces the printed-operator's-manual brief that produced six
6
+ * rejected rounds. The shape of that brief was hairline rules as the only
7
+ * structure, a flat single plane, 2px radii, a saturated navy ground and a
8
+ * monospace face for prose. None of it survives here on purpose.
9
+ *
10
+ * What the design is, in one line each:
11
+ * - the ground is neutral near-black, and the panels are raised objects on
12
+ * it, lit from above with a 1px top highlight and a shadow beneath
13
+ * - size encodes importance: the login that is the problem gets the wide
14
+ * lit panel, the ones that are not get half panels under it
15
+ * - the largest thing on the board is a sentence, not a number
16
+ * - one accent, one blue, on the primary action and nowhere else
17
+ * - severity paints inside a gauge track and marks a state dot; it is never
18
+ * the colour of a word
19
+ * - history sits on the ground, unpanelled, so it cannot compete with what
20
+ * is live
21
+ *
22
+ * ---------------------------------------------------------------------------
23
+ * SHAPES THE TEST SUITE PINS. Each item names the test file that goes red if
24
+ * the shape moves. Read that test before you change the shape it describes.
25
+ * ---------------------------------------------------------------------------
26
+ *
27
+ * 1. The token block below ships HEX values, with the OKLCH the value was
28
+ * designed in written after the semicolon on the same line.
29
+ * test/board-a11y.test.mjs hand-parses that block with a flat regex,
30
+ * resolves var() exactly one level, and converts each value with
31
+ * parseInt on the first six hex digits. A bare oklch() value converts to
32
+ * NaN and the contrast math then fails without saying why. Keep the
33
+ * values hex. Text after the semicolon is outside the captured value,
34
+ * which is what makes the OKLCH comment safe. Two further rules for that
35
+ * block: no comment inside it may contain a semicolon, and none may
36
+ * contain the word color followed by a colon, because either one is read
37
+ * as a declaration.
38
+ * 2. The literal two-selector rule for the html and body elements, carrying
39
+ * both a text colour and a background. board-a11y finds it by matching
40
+ * the trimmed selector exactly, so no comment may sit between the
41
+ * previous rule's closing brace and that selector.
42
+ * 3. At least six rules whose selector contains "chip" or "pill" and that
43
+ * set a text colour, none of them inside a media or keyframes block,
44
+ * which the flat parser does not descend into. board-a11y measures each
45
+ * against var(--panel-2) and needs 3.0:1. In this design .chip is a flat
46
+ * inline data token, not a capsule: the test checks its contrast, not its
47
+ * shape, so the name is load-bearing and is kept honestly.
48
+ * 4. The focus ring. board-a11y requires the focus-visible pseudo-class
49
+ * followed, within eighty characters, by a declaration of a two-pixel
50
+ * solid outline. A box-shadow ring, or the colour split onto its own
51
+ * line, breaks the match. There are two such rules in this file.
52
+ * 5. The [hidden] attribute rule, set to display none with !important,
53
+ * verbatim. test/server.test.mjs matches it in the served /board.css body.
54
+ * 6. The reduced-motion block, keyed on prefers-reduced-motion reduce.
55
+ * board-a11y finds that at-rule by its exact text and brace-matches it,
56
+ * then requires a selector containing state-active followed by an
57
+ * animation of none, and a universal rule setting transition to none.
58
+ * 7. Four scroll boxes, each with its class as the FIRST TOKEN ON ITS LINE:
59
+ * .drawer-task, then .drawer-msg p, then .drawer-timeline, then
60
+ * .drawer-diff. Each rule carries overflow auto or overflow-y auto.
61
+ * test/board-drawer.test.mjs finds them by scanning lines for that
62
+ * prefix, not by parsing CSS. Do not merge them onto one line, do not
63
+ * reformat them, and do not let an earlier line in this file begin with
64
+ * any of those four strings.
65
+ * 8. No positive tabindex, and no outline of none or 0 on a selector that is
66
+ * not itself a focus selector. board-a11y scans board.css, index.html,
67
+ * floor.html, board.js and floor.js for both.
68
+ *
69
+ * Type rule that keeps the 14px floor from regressing: no rule in this file
70
+ * sets a font-size in px. Sizes come from the --t-* scale, and --t--1 is
71
+ * redefined to the body size inside the narrow breakpoint, so the floor is a
72
+ * property of the token rather than something every rule has to remember.
73
+ */
74
+
75
+ @font-face{font-family:"Atkinson Hyperlegible Next";src:url("fonts/atkinson-board.woff2") format("woff2");font-weight:400 700;font-style:normal;font-display:swap}
76
+ @font-face{font-family:"Azeret Mono";src:url("fonts/azeret-board.woff2") format("woff2");font-weight:400 600;font-style:normal;font-display:swap}
77
+
78
+ :root {
79
+ /* families. Atkinson is self-hosted and stays: it is a real face with a
80
+ purpose rather than the system stack, and a board read at a glance all day
81
+ is exactly what hyperlegible letterforms are for. Azeret is demoted to
82
+ true data tokens only, never prose */
83
+ --sans: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", sans-serif;
84
+ --mono: "Azeret Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
85
+
86
+ /* ground and elevation, hue 258 at a chroma low enough never to read as
87
+ navy. The old ramp was the same hue at four times this chroma */
88
+ --e0: #0E1012; /* oklch(0.170 0.006 258) page ground, unlit */
89
+ --e1: #171A1D; /* oklch(0.215 0.007 258) recessed well, tracks, wells, inputs */
90
+ --e2: #212427; /* oklch(0.258 0.008 258) the raised panel */
91
+ --panel-2: #212427; /* oklch(0.258 0.008 258) the panel again, under the name the contrast test reads */
92
+ --e3: #2C2F34; /* oklch(0.305 0.009 258) quiet control */
93
+ --e4: #363A40; /* oklch(0.345 0.010 258) control hover */
94
+ --selected: #2A3340; /* oklch(0.300 0.020 258) selected row */
95
+ --track: #2B2E32; /* oklch(0.300 0.008 258) gauge track */
96
+
97
+ /* edges. --line separates siblings INSIDE one panel and is the only rule
98
+ left in the design. --edge is the panel boundary, barely there */
99
+ --line: #35383D; /* oklch(0.335 0.008 258) sibling separator inside one panel */
100
+ --line-strong: #4A4E55; /* oklch(0.420 0.009 258) section rule on the ground */
101
+ --edge: #2A2D31; /* oklch(0.285 0.008 258) panel boundary */
102
+ --edge-hi: #4B5058; /* oklch(0.430 0.010 258) the top edge that catches light */
103
+ --control-edge: #686D75; /* oklch(0.540 0.010 258) quiet control boundary, 3.00:1 on the panel */
104
+
105
+ /* text */
106
+ --text: #F3F4F7; /* oklch(0.960 0.004 258) primary, 14.18:1 on the panel */
107
+ --text-2: #B9BEC6; /* oklch(0.800 0.010 258) secondary, 8.35:1 on the panel */
108
+ --text-3: #9399A1; /* oklch(0.680 0.012 258) meta, 5.43:1 on the panel */
109
+ --text-dim: #6B7079; /* oklch(0.540 0.012 258) disabled only, never encodes state */
110
+
111
+ /* the one accent. It means: this is the action to take. Nowhere else */
112
+ --accent: #5C91FF; /* oklch(0.680 0.185 264) */
113
+ --accent-hi: #7CADFF; /* oklch(0.760 0.150 264) */
114
+ --on-accent: #070D1A; /* oklch(0.150 0.030 264) 6.43:1 on the accent */
115
+
116
+ /* state. A fill paints inside a track and a mark is a dot. The -text
117
+ variants exist for the few places a word must carry the state itself */
118
+ --calm: #5F6670; /* oklch(0.520 0.012 258) a healthy gauge is quiet, not green */
119
+ --ok: #3FA463; /* oklch(0.620 0.140 152) success fill */
120
+ --ok-text: #6FD68F; /* oklch(0.800 0.130 150) success mark and sentence */
121
+ --warn: #E0A33C; /* oklch(0.760 0.140 75) gauge segment 60 to 85 */
122
+ --warn-text: #F0BE6E; /* oklch(0.830 0.110 78) warning mark and sentence */
123
+ --danger: #E64343; /* oklch(0.620 0.200 25) gauge segment past 85, and the walled fill */
124
+ --danger-text: #FF8A8A; /* oklch(0.760 0.150 22) error mark and sentence */
125
+
126
+ /* interaction and identity. Identity is a dot beside a printed name and the
127
+ gauge fill. It is never text colour on a row and never a border */
128
+ --focus: #EDF2FA; /* oklch(0.960 0.010 258) focus ring, appears nowhere else */
129
+ --id-claude: #FCA169; /* oklch(0.790 0.130 52) warm */
130
+ --id-codex: #69DBBA; /* oklch(0.815 0.115 172) cool green */
131
+ --id-agy: #CC97F3; /* oklch(0.760 0.140 310) violet, 46 degrees clear of the accent */
132
+ --id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
133
+
134
+ /* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
135
+ is why it read as a spreadsheet */
136
+ --t--1: 0.8125rem; /* 13px the row register and the scaffolding strip */
137
+ --t-0: 0.9375rem; /* 15px labels, meta, controls, the narrow floor */
138
+ --t-1: 1.0625rem; /* 17px prompts, panel heads, body default */
139
+ --t-2: 1.3125rem; /* 21px section heads, secondary readings */
140
+ --t-3: 2.125rem; /* 34px the gauge numeral */
141
+ --t-4: 3.25rem; /* 52px the verdict */
142
+
143
+ --w-text: 400;
144
+ --w-head: 700;
145
+
146
+ --r-panel: 16px;
147
+ --r-well: 12px;
148
+ --r-control: 10px;
149
+
150
+ --ease: cubic-bezier(.2,0,0,1);
151
+
152
+ /* one raised object, lit from above. The inset highlight is the top edge and
153
+ the two shadows are contact and ambient */
154
+ --lift: inset 0 1px 0 rgba(255,255,255,.05), 0 1px 2px rgba(0,0,0,.45), 0 18px 44px -14px rgba(0,0,0,.70);
155
+ --lift-hi: inset 0 1px 0 rgba(255,255,255,.10), 0 1px 2px rgba(0,0,0,.45), 0 22px 52px -14px rgba(0,0,0,.75);
156
+ --sink: inset 0 1px 3px rgba(0,0,0,.50);
157
+ }
158
+
159
+ * { box-sizing: border-box; }
160
+
161
+ html, body { color: var(--text); background: var(--e0); }
162
+
163
+ body {
164
+ margin: 0;
165
+ font-family: var(--sans);
166
+ font-size: var(--t-1);
167
+ line-height: 1.5;
168
+ -webkit-font-smoothing: antialiased;
169
+ text-rendering: optimizeLegibility;
170
+ font-variant-numeric: tabular-nums;
171
+ font-feature-settings: "tnum" 1;
172
+ padding-bottom: env(safe-area-inset-bottom, 0px);
173
+ }
174
+
175
+ h1, h2, h3, p, ul, ol, dl, dd, figure { margin: 0; }
176
+ ul, ol { padding: 0; list-style: none; }
177
+ button { font: inherit; color: inherit; margin: 0; }
178
+ code { font-family: var(--mono); font-size: 0.94em; }
179
+
180
+ [hidden] { display: none !important; }
181
+
182
+ /* one focus treatment for every control on the board */
183
+ :where(button, a, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: var(--r-control); }
184
+ .panel-prompt:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
185
+
186
+ /* ---- shell ------------------------------------------------------------- */
187
+ .wrap { max-width: 1120px; margin: 0 auto; padding-inline: clamp(20px, 4vw, 40px); padding-bottom: 72px; }
188
+
189
+ .banner { background: var(--e1); border-bottom: 1px solid var(--edge); color: var(--text-2); font-size: var(--t-0); padding: 10px clamp(20px, 4vw, 40px); padding-top: calc(10px + env(safe-area-inset-top, 0px)); }
190
+
191
+ .masthead { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-block: 28px 0; }
192
+ .brand { font-size: var(--t-1); font-weight: var(--w-head); letter-spacing: -.01em; }
193
+ .brand-glyph { display: inline-block; margin-right: 4px; }
194
+ .masthead-right { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
195
+ .floor-link { color: var(--text-2); text-decoration: none; border-bottom: 1px solid var(--line-strong); padding-bottom: 1px; }
196
+ .floor-link:hover { color: var(--text); border-bottom-color: var(--text-2); }
197
+
198
+ .sse-status { display: inline-flex; align-items: center; gap: 8px; }
199
+ .sse-word { color: var(--text-3); }
200
+ .sse-rule { width: 8px; height: 8px; border-radius: 999px; background: var(--ok); flex: none; }
201
+ .sse-rule.is-connecting { background: var(--warn); }
202
+ .sse-rule.is-down { background: var(--danger); }
203
+ .sched-status { color: var(--text-3); }
204
+
205
+ /* ---- the verdict: the largest thing on the board is a sentence ---------- */
206
+ .verdict { padding-block: 56px 56px; }
207
+ .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; }
208
+ .verdict p { margin-top: 16px; font-size: var(--t-2); line-height: 1.5; color: var(--text-2); max-width: 54ch; text-wrap: pretty; }
209
+ .verdict p:empty { display: none; }
210
+
211
+ /* ---- panel: a raised object, not a drawn rectangle ---------------------- */
212
+ .panel { position: relative; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-panel); padding: 32px; box-shadow: var(--lift); }
213
+ .panel--lit { box-shadow: var(--lift-hi); }
214
+
215
+ .panel-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
216
+ .who { display: inline-flex; align-items: center; gap: 10px; font-size: var(--t-1); font-weight: var(--w-head); }
217
+ .who-note { font-size: var(--t-0); color: var(--text-3); }
218
+
219
+ .dot { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--id-fake); }
220
+ .dot.id-claude { background: var(--id-claude); }
221
+ .dot.id-codex { background: var(--id-codex); }
222
+ .dot.id-agy { background: var(--id-agy); }
223
+
224
+ /* ---- logins ------------------------------------------------------------- */
225
+ .logins { display: grid; gap: 20px; }
226
+ .logins-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
227
+ .head-caption { margin-top: 16px; font-size: var(--t-0); color: var(--text-3); }
228
+
229
+ /* ---- the gauge: the instrument ------------------------------------------ */
230
+ .gauge-block + .gauge-block { margin-top: 26px; }
231
+ .gauge { display: flex; align-items: center; gap: 20px; }
232
+ .gauge-label { flex: none; width: 62px; font-size: var(--t-0); color: var(--text-3); }
233
+ .gauge--none .gauge-label { width: auto; }
234
+ .gauge-track { flex: 1 1 auto; min-width: 0; position: relative; height: 16px; background: var(--track); border-radius: 999px; box-shadow: inset 0 1px 2px rgba(0,0,0,.45); overflow: hidden; }
235
+ .gauge--minor .gauge-track { height: 8px; }
236
+ .gauge-fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: var(--calm); }
237
+ /* the reserve notch: a 2px gap cut through the bar where 85 percent sits */
238
+ .gauge-post { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--e1); }
239
+ .gauge-readout { flex: none; width: 168px; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
240
+ .gauge--noread::after { content: ""; flex: none; width: 96px; }
241
+ .gauge-read { text-align: right; font-size: var(--t-3); font-weight: var(--w-head); letter-spacing: -.02em; line-height: 1; }
242
+ .gauge--minor .gauge-read { font-size: var(--t-2); }
243
+ .gauge-read--none { font-size: var(--t-2); font-weight: var(--w-text); color: var(--text-3); }
244
+ .gauge-note { font-size: var(--t-0); color: var(--text-3); white-space: nowrap; }
245
+
246
+ .reading { margin-top: 22px; font-size: var(--t-2); font-weight: var(--w-head); line-height: 1.3; letter-spacing: -.012em; }
247
+ .reading-sub { margin-top: 8px; font-size: var(--t-0); color: var(--text-2); max-width: 46ch; }
248
+ .reading-sub--lead { margin-top: 22px; }
249
+
250
+ /* ---- section heads ------------------------------------------------------ */
251
+ .section-head { padding-block: 56px 20px; }
252
+ .section-head h2 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
253
+ .section-head .region-meta { margin-top: 8px; font-size: var(--t-0); color: var(--text-3); }
254
+ .section-head--quiet { padding-block: 56px 16px; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
255
+ .section-head--quiet .region-meta { margin-top: 0; }
256
+ .hoisted { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; font-size: var(--t-0); }
257
+ .hoisted-note { color: var(--text-2); }
258
+ .hoisted-note.tone-warn { color: var(--warn-text); }
259
+ .hoisted-note.tone-danger { color: var(--danger-text); }
260
+ .hoisted-note.tone-muted { color: var(--text-3); }
261
+
262
+ /* ---- terminals: rows inside one panel, separated by the only rule left --- */
263
+ .terms { padding: 4px 0; }
264
+ .term { padding: 26px 32px; }
265
+ .term + .term { border-top: 1px solid var(--line); }
266
+ .term.is-urgent { background: linear-gradient(to right, rgba(230,67,67,.07), transparent 40%); }
267
+ .term-row { display: flex; align-items: center; gap: 28px; }
268
+ .term-body { flex: 1 1 auto; min-width: 0; }
269
+ .term-row > .term-body { flex-basis: 24ch; }
270
+ .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; }
271
+ .term-register { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
272
+ .term-where { color: var(--text-2); }
273
+ .term-when { color: var(--text-3); min-width: 6ch; text-align: right; }
274
+ .term-id { color: var(--text-3); min-width: 4ch; text-align: right; font-family: var(--mono); }
275
+ .term-actions { flex: none; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; width: 272px; }
276
+ .term-actions .btn { white-space: nowrap; width: 100%; }
277
+ /* a row with one or three controls keeps the grid honest rather than stretching
278
+ the odd one across both columns */
279
+ .term-actions .btn:only-child { grid-column: 1 / -1; }
280
+
281
+ .state { display: inline-flex; align-items: center; gap: 8px; color: var(--text-2); font-weight: var(--w-head); }
282
+ .mark { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--text-3); }
283
+ .mark.tone-ok { background: var(--ok); }
284
+ .mark.tone-warn { background: var(--warn); }
285
+ .mark.tone-danger { background: var(--danger); }
286
+ .mark.state-active { background: var(--text); }
287
+ @media (prefers-reduced-motion: no-preference) {
288
+ .mark.state-active { animation: breathe 2400ms var(--ease) infinite; }
289
+ }
290
+ @keyframes breathe { 0%,100% { opacity: 1 } 50% { opacity: .45 } }
291
+
292
+ .term-prompt { margin-top: 14px; font-size: var(--t-1); line-height: 1.45; max-width: 62ch; color: var(--text); }
293
+ .term-prompt--empty { color: var(--text-3); }
294
+ .panel-prompt { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; margin-top: 14px; font-size: var(--t-1); line-height: 1.45; max-width: 62ch; color: var(--text); cursor: pointer; }
295
+ .panel-prompt:hover { color: var(--accent-hi); }
296
+
297
+ .sentence { margin-top: 10px; font-size: var(--t-0); color: var(--text-2); max-width: 64ch; }
298
+ .sentence.tone-warn { color: var(--warn-text); }
299
+ .sentence.tone-danger { color: var(--danger-text); }
300
+ .sentence.tone-ok { color: var(--ok-text); }
301
+ .sentence.tone-muted { color: var(--text-3); }
302
+ .files { margin-top: 10px; font-size: var(--t--1); color: var(--text-3); }
303
+ .file { color: var(--text-3); }
304
+ .file.is-overlap { color: var(--warn-text); }
305
+ .blocker { margin-top: 10px; font-size: var(--t-0); color: var(--text-3); max-width: 64ch; }
306
+ .blocker.is-hoisted { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
307
+
308
+ /* the flat inline data token. Not a capsule: a chip here is a word set in the
309
+ meta colour beside the thing it qualifies */
310
+ .chip { font-size: var(--t--1); color: var(--text-2); font-family: var(--mono); }
311
+ .chip.is-stale { color: var(--text-3); }
312
+ .chip-id-claude { color: var(--id-claude); }
313
+ .chip-id-codex { color: var(--id-codex); }
314
+ .chip-id-agy { color: var(--id-agy); }
315
+ .chip-id-fake { color: var(--id-fake); }
316
+ .chip-state-ok { color: var(--ok-text); }
317
+ .chip-state-warn { color: var(--warn-text); }
318
+
319
+ .acct-name { font-weight: var(--w-head); }
320
+ .acct-name.id-claude { color: var(--id-claude); }
321
+ .acct-name.id-codex { color: var(--id-codex); }
322
+ .acct-name.id-agy { color: var(--id-agy); }
323
+ .acct-name.id-fake { color: var(--id-fake); }
324
+
325
+ /* ---- controls ----------------------------------------------------------- */
326
+ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 0 16px; border-radius: var(--r-control); font-size: var(--t-0); font-weight: var(--w-head); letter-spacing: -.005em; cursor: pointer; border: 1px solid transparent; background: none; transition: background-color 150ms var(--ease), border-color 150ms var(--ease), transform 150ms var(--ease), color 150ms var(--ease); }
327
+ .btn-primary { background: var(--accent); color: var(--on-accent); }
328
+ .btn-primary:hover { background: var(--accent-hi); }
329
+ .btn-secondary { background: var(--e3); color: var(--text); border-color: var(--control-edge); }
330
+ .btn-secondary:hover { background: var(--e4); border-color: #7D828A; }
331
+ .btn-danger { background: var(--e3); color: var(--danger-text); border-color: var(--control-edge); }
332
+ .btn-danger:hover { background: var(--e4); }
333
+ .btn-text { min-height: 32px; padding: 0 2px; background: none; color: var(--text-2); border: 0; text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--line-strong); }
334
+ .btn-text:hover { color: var(--text); text-decoration-color: var(--text-2); }
335
+ .btn:active { transform: translateY(1px); }
336
+ .btn:disabled { color: var(--text-dim); background: var(--e1); border-color: var(--edge); cursor: not-allowed; }
337
+ .btn:disabled:hover { background: var(--e1); }
338
+
339
+ /* ---- disclosure wells --------------------------------------------------- */
340
+ .drawer { margin-top: 22px; background: var(--e1); border-radius: var(--r-well); padding: 24px; box-shadow: var(--sink); }
341
+ .drawer-head { font-size: var(--t-1); font-weight: var(--w-head); margin-bottom: 18px; }
342
+ .drawer-note { margin-top: 18px; font-size: var(--t-0); color: var(--text-2); max-width: 64ch; }
343
+ .drawer-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
344
+
345
+ /* the four scroll boxes board-drawer.test.mjs scans for by line prefix. Each
346
+ class is the first token on its line, in this order, and no earlier line in
347
+ this file begins with any of the four */
348
+ .drawer-task { max-height: 22rem; overflow: auto; font-size: var(--t-0); color: var(--text-2); }
349
+ .drawer-msg p { max-height: 18rem; overflow-y: auto; font-size: var(--t-0); color: var(--text-2); }
350
+ .drawer-timeline { max-height: 22rem; overflow: auto; font-size: var(--t-0); }
351
+ .drawer-diff { max-height: 26rem; overflow: auto; font-family: var(--mono); font-size: var(--t--1); }
352
+
353
+ .drawer-msg { margin-top: 14px; }
354
+ .drawer-msg-role { font-size: var(--t--1); color: var(--text-3); font-weight: var(--w-head); }
355
+ .drawer-msg-when { font-size: var(--t--1); color: var(--text-3); }
356
+
357
+ /* ---- ledger: on the ground, unpanelled --------------------------------- */
358
+ .ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
359
+ .ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
360
+ .ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
361
+ .ledger-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
362
+ .ledger-cell .empty-line { margin-top: 12px; font-size: var(--t-0); color: var(--text-3); }
363
+
364
+ .list { margin-top: 20px; }
365
+ .list li { padding: 14px 0; font-size: var(--t-0); }
366
+ .list li + li { border-top: 1px solid var(--line); }
367
+ .line { display: grid; grid-template-columns: 72px 1fr auto; gap: 8px 20px; align-items: baseline; }
368
+ .line--commit { grid-template-columns: 1fr auto; }
369
+ .line-main { color: var(--text); }
370
+ .line-meta { color: var(--text-2); }
371
+ .line-when { color: var(--text-3); text-align: right; }
372
+ .group-head { display: flex; align-items: baseline; gap: 10px; margin-top: 24px; margin-bottom: 4px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
373
+ .group-head:first-child { margin-top: 0; }
374
+ .group-head span { font-weight: var(--w-text); color: var(--text-3); }
375
+
376
+ .trunk-row { display: grid; grid-template-columns: 1fr auto; gap: 6px 20px; align-items: baseline; padding: 14px 0; font-size: var(--t-0); }
377
+ .trunk-row + .trunk-row { border-top: 1px solid var(--line); }
378
+ .landed-by { color: var(--text-3); }
379
+ .finished-line { padding: 14px 0; font-size: var(--t-0); }
380
+ .finished-names { margin-left: 10px; color: var(--text-3); }
381
+
382
+ /* ---- cards -------------------------------------------------------------- */
383
+ .card-list { display: grid; gap: 14px; }
384
+ /* A card row reads down the same four columns as an account and a terminal
385
+ (board.js buildRow): R1 who, R2 what, R3 where, R4 when-act. The first three
386
+ stack in the body column; R4 is the clock and the actions on the right, the
387
+ same shape as .term-row. Without this the four cells fell back to block
388
+ defaults and the row read as unstyled markup. */
389
+ .row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 28px; align-items: start; padding: 26px 32px; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-well); }
390
+ .r1, .r2, .r3 { grid-column: 1; min-width: 0; }
391
+ .r1 { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
392
+ .r2 { margin-top: 8px; }
393
+ .r3 { margin-top: 12px; display: grid; gap: 8px; }
394
+ .r4 { grid-column: 2; grid-row: 1 / span 3; display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
395
+ .r4 .elapsed { font-size: var(--t--1); white-space: nowrap; }
396
+ /* the same 2x2 block the terminal rows use, so the two row kinds line up */
397
+ .r4 .row-actions { margin-top: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; width: 272px; }
398
+ .r4 .row-actions .btn { white-space: nowrap; width: 100%; }
399
+ .r4 .row-actions .btn:only-child { grid-column: 1 / -1; }
400
+ .leases { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
401
+ .reassign-picker { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
402
+ /* a bare mono span: the chips carry their own font, this one does not */
403
+ .mono { font-family: var(--mono); }
404
+ .row:hover { background: var(--e3); }
405
+ .row.is-selected { background: var(--selected); }
406
+ /* a bare <button> keeps the UA's light-grey fill and padding, which put
407
+ near-white text on near-white and made the card title unreadable. Reset it
408
+ the way .panel-prompt does for terminal rows. */
409
+ .row-title { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; font-size: var(--t-1); line-height: 1.45; color: var(--text); cursor: pointer; }
410
+ .row-title:hover { color: var(--accent-hi); }
411
+ .row-meta { font-size: var(--t-0); color: var(--text-3); display: flex; gap: 14px; flex-wrap: wrap; }
412
+ .row-actions { margin-top: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
413
+ /* the demoted-notes disclosure in a terminal row */
414
+ .also { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
415
+ .status-word { font-weight: var(--w-head); color: var(--text-2); }
416
+ .elapsed { font-family: var(--mono); color: var(--text-3); }
417
+ .where { color: var(--text-2); }
418
+ .worktree { color: var(--text-3); }
419
+
420
+ /* ---- detail panes ------------------------------------------------------- */
421
+ .detail { margin-top: 22px; background: var(--e1); border-radius: var(--r-well); box-shadow: var(--sink); }
422
+ .detail-inner { padding: 24px; }
423
+ .detail-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; font-size: var(--t-1); font-weight: var(--w-head); margin-bottom: 12px; }
424
+ .detail-section { margin-top: 20px; }
425
+ .detail-sub { font-size: var(--t-0); color: var(--text-3); margin-bottom: 8px; }
426
+ .kv { display: grid; grid-template-columns: 148px 1fr; gap: 10px 20px; font-size: var(--t-0); }
427
+ .kv-key { color: var(--text-3); }
428
+ .kv-val { color: var(--text); }
429
+ .well { background: var(--e0); border-radius: var(--r-control); padding: 14px; }
430
+ .log-pre { font-family: var(--mono); font-size: var(--t--1); white-space: pre-wrap; color: var(--text-2); max-height: 26rem; overflow: auto; }
431
+ .hunk { font-family: var(--mono); font-size: var(--t--1); white-space: pre-wrap; }
432
+ .file-row { display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 8px; text-align: left; }
433
+ .turn { padding: 12px 0; }
434
+ .turn + .turn { border-top: 1px solid var(--line); }
435
+ .turn-role { font-size: var(--t--1); color: var(--text-3); font-weight: var(--w-head); }
436
+ .turn-when { font-size: var(--t--1); color: var(--text-3); }
437
+ /* the kind word sits beside the clock, not welded to it: `10:40 AMstarted` */
438
+ .turn-when + .turn-role { margin-left: 10px; }
439
+
440
+ /* ---- chain rail, order rows, confirm ------------------------------------ */
441
+ .chain-rail { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
442
+ .cap-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px; font-size: var(--t-0); color: var(--text-3); margin-top: 8px; }
443
+ .order-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; font-size: var(--t-0); }
444
+ .order-row-name { flex: 1 1 auto; }
445
+ /* the label on each agent row in the New card dialog */
446
+ .fallback-row-title { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
447
+ .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); }
448
+ .actions { display: flex; gap: 10px; flex-wrap: wrap; }
449
+
450
+ /* ---- the floor: the scheduler's view, tables on the same ground --------- */
451
+ .masthead-left { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; }
452
+ .repos-status, .counts-status { color: var(--text-3); }
453
+ .counts-status span { color: var(--text-2); font-weight: var(--w-head); margin-left: 2px; }
454
+ .region-floor { margin-top: 12px; }
455
+ .ftable { width: 100%; border-collapse: collapse; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-panel); box-shadow: var(--lift); overflow: hidden; font-size: var(--t-0); }
456
+ .ftable th, .ftable td { text-align: left; vertical-align: top; padding: 14px 18px; }
457
+ .ftable thead th { color: var(--text-3); font-weight: var(--w-head); font-size: var(--t--1); border-bottom: 1px solid var(--line); }
458
+ .ftable tbody tr + tr td { border-top: 1px solid var(--line); }
459
+ .ftable tbody tr:hover td { background: var(--e3); }
460
+ .ftable td.c-elapsed, .ftable td.c-since, .ftable td.c-time { font-family: var(--mono); color: var(--text-3); white-space: nowrap; }
461
+ .ftable .c-actions { text-align: right; }
462
+ .ftable .c-actions .btn { white-space: nowrap; }
463
+ .ftable-wrap { overflow-x: auto; }
464
+ .ftable td.is-empty { color: var(--text-3); }
465
+
466
+ /* ---- settings and forms ------------------------------------------------- */
467
+ .settings-body { display: grid; gap: 24px; }
468
+ .field { display: grid; gap: 8px; }
469
+ .field label, .form-row label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
470
+ .field-help { font-size: var(--t-0); color: var(--text-3); max-width: 64ch; }
471
+ .field-status { font-size: var(--t-0); color: var(--text-3); }
472
+ .board-facts { font-size: var(--t-0); color: var(--text-3); }
473
+ input[type="text"], input[type="password"], textarea, select { font: inherit; font-size: var(--t-0); color: var(--text); background: var(--e0); border: 1px solid var(--control-edge); border-radius: var(--r-control); padding: 9px 12px; min-height: 36px; }
474
+ input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; }
475
+ fieldset { border: 1px solid var(--edge); border-radius: var(--r-well); padding: 18px; display: grid; gap: 12px; }
476
+ legend { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); padding-inline: 6px; }
477
+ .form-row { display: grid; gap: 8px; margin-top: 18px; }
478
+ .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); }
479
+ .advanced-options { margin-top: 22px; }
480
+ .advanced-options summary { cursor: pointer; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
481
+ .dialog-title { font-size: var(--t-2); font-weight: var(--w-head); }
482
+ .dialog-intro { margin-top: 8px; font-size: var(--t-0); color: var(--text-3); max-width: 64ch; }
483
+ .dialog-actions { margin-top: 26px; display: flex; gap: 10px; justify-content: flex-end; }
484
+ dialog { background: var(--e2); color: var(--text); border: 1px solid var(--edge); border-radius: var(--r-panel); box-shadow: var(--lift-hi); padding: 32px; max-width: 640px; width: calc(100vw - 40px); }
485
+ dialog::backdrop { background: rgba(0,0,0,.6); }
486
+
487
+ .empty-line { font-size: var(--t-0); color: var(--text-3); margin-top: 18px; max-width: 64ch; }
488
+ .sysmsg { position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); display: grid; gap: 8px; z-index: 50; }
489
+ .sysmsg:empty { display: none; }
490
+ .sysmsg-item { background: var(--e4); color: var(--text); border-radius: var(--r-control); padding: 12px 16px; font-size: var(--t-0); box-shadow: var(--lift); }
491
+ .prov { font-size: var(--t--1); color: var(--text-3); }
492
+ .prov.is-stale { color: var(--warn-text); }
493
+ .tone-muted { color: var(--text-3); }
494
+ .tone-warn { color: var(--warn-text); }
495
+ .tone-danger { color: var(--danger-text); }
496
+ .tone-ok { color: var(--ok-text); }
497
+
498
+ /* ===========================================================================
499
+ narrow. One breakpoint, because the layout has one narrow form: everything
500
+ in a single column. --t--1 is redefined to the body size here, which is what
501
+ holds the 14px floor without every rule having to remember it.
502
+ =========================================================================== */
503
+ @media (max-width: 760px) {
504
+ .wrap { --t-4: 2rem; --t-3: 1.75rem; --t-2: 1.1875rem; --t--1: 0.9375rem; }
505
+ .verdict { padding-block: 36px 32px; }
506
+ .verdict h1 { max-width: none; letter-spacing: -.02em; }
507
+ .verdict p { margin-top: 16px; }
508
+ .panel { padding: 22px; border-radius: 14px; }
509
+ .logins-pair { grid-template-columns: 1fr; }
510
+ .gauge { gap: 14px; flex-wrap: wrap; }
511
+ .gauge-label { width: 100%; }
512
+ .gauge-track { flex: 1 1 110px; }
513
+ .gauge-readout { display: contents; }
514
+ .gauge-read { width: 68px; }
515
+ .gauge-note { width: 100%; text-align: left; margin-top: 8px; }
516
+ .gauge--noread::after { width: 68px; }
517
+ .gauge-block + .gauge-block { margin-top: 22px; }
518
+ code { font-size: 1em; }
519
+ .section-head { padding-block: 40px 16px; }
520
+ .section-head--quiet { padding-block: 40px 16px; }
521
+ .term { padding: 22px; }
522
+ .term-row { flex-direction: column; align-items: stretch; gap: 14px; }
523
+ .term-clock { order: -1; width: auto; justify-content: flex-end; gap: 14px; }
524
+ .term-id { min-width: 0; }
525
+ .term-actions { width: auto; justify-content: flex-start; }
526
+ .term-actions .btn { flex: 1 1 auto; }
527
+ .term-register { gap: 10px 14px; }
528
+ .kv { grid-template-columns: 1fr; gap: 4px 0; }
529
+ .kv-val + .kv-key { margin-top: 12px; }
530
+ .ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
531
+ .line { grid-template-columns: 1fr auto; gap: 6px 14px; }
532
+ .line-main { grid-column: 1 / -1; }
533
+ .line-meta { grid-column: 1; }
534
+ .line--commit .line-main { grid-column: 1; }
535
+ .drawer { padding: 18px; }
536
+ dialog { padding: 22px; }
537
+ }
538
+
539
+ @media (prefers-reduced-motion: reduce) {
540
+ .mark.state-active { animation: none; }
541
+ * { transition: none !important; }
542
+ .btn:active { transform: none; }
543
+ }