@ucsandman/legcli 0.15.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +5 -3
- package/docs/ERRORS.md +39 -0
- package/docs/board-guide.md +33 -29
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/package.json +1 -1
- package/scripts/npm-publish-gate.mjs +4 -0
- package/scripts/release-notes.mjs +51 -0
- package/src/board/board.css +177 -55
- package/src/board/board.js +5 -2
- package/src/board/entry.js +4 -4
- package/src/board/favicon.svg +1 -1
- package/src/board/floor.html +2 -2
- package/src/board/floor.js +4 -1
- package/src/board/history.js +1 -1
- package/src/board/index.html +22 -8
- package/src/board/sessions.js +55 -14
- package/src/board/strip.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
The board reads as one product: a polish pass inside the 2026-09-15
|
|
6
|
+
direction, a new mark, and a first-run screen.
|
|
7
|
+
|
|
8
|
+
- **One colour rule, kept everywhere.** Login names in the capacity strip,
|
|
9
|
+
the rung in a terminal row's register and the rungs in the ladder editor are
|
|
10
|
+
text beside an identity dot; the colour no longer lands on the word. A
|
|
11
|
+
warning sentence is amber only on a row that is waiting on you. The accent
|
|
12
|
+
blue appears on Land when it can run and on Start once a task is typed, and
|
|
13
|
+
nowhere else. The capacity strip is an aligned table (dot, name, track,
|
|
14
|
+
figure) instead of a ragged sentence.
|
|
15
|
+
- **Terminal rows.** The prompt is clamped to two lines (Details holds the
|
|
16
|
+
rest), the clock sits over one quiet row of buttons, a Land that cannot run
|
|
17
|
+
is not drawn (its reason is said once above the panel), and End is quiet at
|
|
18
|
+
rest. The register moved up to 15px and the scaffolding floor from 13 to
|
|
19
|
+
14px. A row whose state changed since the last render is lit for 1.4 s, the
|
|
20
|
+
one motion the rows own; rows carry a faint hover. Narrow rows lost 264px of
|
|
21
|
+
empty height each: a `flex-basis: 24ch` meant for the row direction had been
|
|
22
|
+
applied as height once the row stacked. The 420px horizontal overflow is
|
|
23
|
+
gone.
|
|
24
|
+
- **One control language.** The background entry is the terminals panel's
|
|
25
|
+
footer, its choices quiet chips inside the sentence. Ledger cells share one
|
|
26
|
+
anatomy with their verbs (View, Browse, New card) on one baseline; Settings
|
|
27
|
+
is a row in the same grammar; disclosures carry a CSS chevron instead of a
|
|
28
|
+
typed `>`; the Floor and Board links are buttons.
|
|
29
|
+
- **The verdict is a link.** When the sentence names a terminal, pressing it
|
|
30
|
+
scrolls to that row and focuses its prompt.
|
|
31
|
+
- **First run.** A board with no terminal shows one lit panel: `leg claude`,
|
|
32
|
+
`leg codex` and `leg agy` each with a Copy, and one line on what appears
|
|
33
|
+
with the first turn.
|
|
34
|
+
- **The floor.** An empty station is one line, heading beside sentence, so
|
|
35
|
+
seven empty stations are seven lines rather than seven screens.
|
|
36
|
+
- **The mark.** A tile with the L cut as hip, knee and foot replaces the 🦿
|
|
37
|
+
emoji on the board, the floor, the favicon, the tab badge and the site.
|
|
38
|
+
The emoji rendered as whichever system emoji font the machine had.
|
|
39
|
+
- **Docs.** Nine screenshots retaken against this build; DESIGN.md and the
|
|
40
|
+
board guide describe the new row and strip; the OG image carries the mark.
|
|
41
|
+
|
|
3
42
|
## 0.15.1 (2026-09-19)
|
|
4
43
|
|
|
5
44
|
Your own Claude Code status line comes back.
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
*Claude hits the five-hour wall. The terminal reads `handing off to codex`, and codex carries on there. Nothing is retyped. ([the full 53-second run](https://legcli.com/#handoff))*
|
|
13
13
|
|
|
14
|
-

|
|
15
15
|
|
|
16
16
|
You keep using your coding agents exactly as you do today, in any terminal,
|
|
17
17
|
from your own config directory: Leg adds its hooks in a separate per-session
|
|
@@ -420,8 +420,10 @@ with nothing in it says so with its counts.
|
|
|
420
420
|
first, each row with the short sha, the subject, a `repo@branch` chip, and
|
|
421
421
|
when plus who. A commit a Land put there says `landed by <agent> (<id tail>)`.
|
|
422
422
|
- **Buttons**, in a fixed order that never reflows: Land, Hand off now,
|
|
423
|
-
Details, End
|
|
424
|
-
|
|
423
|
+
Details, End, under the clock at the right of the row. Land is drawn only
|
|
424
|
+
when it can run; its reason is said once above the panel. Once a session has
|
|
425
|
+
ended, Remove and Remove record take End's place. Details opens an expansion
|
|
426
|
+
in flow under the panel.
|
|
425
427
|
**Hand off now** takes the first open rung of the ladder. To name the
|
|
426
428
|
destination instead, open Details and use **Hand off now to**, which lists
|
|
427
429
|
every rung with its model, whether it keeps the conversation, and the reason
|
package/docs/ERRORS.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
What broke, why, and what fixed it. One entry per failure, newest first. A first
|
|
4
4
|
occurrence has to be written down or a repeat is never countable.
|
|
5
5
|
|
|
6
|
+
## 2026-09-19: eleven versions reached npm and none reached the GitHub releases page
|
|
7
|
+
|
|
8
|
+
**Fixed by making the publish job create the tag and the GitHub release itself,
|
|
9
|
+
from the version's CHANGELOG.md section, and by backfilling v0.7.0 to v0.15.1.**
|
|
10
|
+
|
|
11
|
+
The releases page stopped at v0.6.0 (2026-09-15, the pre-rename name) while npm went on to
|
|
12
|
+
0.15.1. Releases 0.2.0 to 0.6.0 had been made by hand with `gh release create`,
|
|
13
|
+
batched on 09-11 and 09-15; once `publish-npm` in `.github/workflows/ci.yml`
|
|
14
|
+
took over the npm side on 09-14, nothing tagged or drafted anything, and no
|
|
15
|
+
check noticed, because a green publish job and a stale releases page look the
|
|
16
|
+
same from the terminal. Wes found it on the page. The rule that holds is a
|
|
17
|
+
mechanism, not a note: the publish job now runs `scripts/release-notes.mjs`
|
|
18
|
+
and `gh release create v<version>` in the same job as `npm publish` (the job
|
|
19
|
+
has `contents: write` for that one step), and `scripts/npm-publish-gate.mjs`
|
|
20
|
+
refuses a version with no `## <version>` section in CHANGELOG.md, so a
|
|
21
|
+
release nobody wrote up never reaches npm either. `test/release-notes.test.mjs`
|
|
22
|
+
pins the extraction. The backfilled tags point at the commit each npm version
|
|
23
|
+
was published from (`npm view @ucsandman/legcli time` matched against
|
|
24
|
+
`git log --first-parent --before`), not at the `[RELEASE]` commit, since three
|
|
25
|
+
of those needed a follow-up fix before the gate let them through. 0.6.1 and
|
|
26
|
+
0.8.1 have CHANGELOG sections but never reached npm, so they have no release.
|
|
27
|
+
|
|
6
28
|
## 2026-09-18: 0.14.0 pushed, CI green everywhere, and npm still served 0.13.1
|
|
7
29
|
|
|
8
30
|
**Fixed by putting the `repository` block back into the lockfile root
|
|
@@ -687,3 +709,20 @@ it), and check whether a board was listening on 4747 at the time.
|
|
|
687
709
|
- **The lesson that generalises.** A seeded board is safe to look at and not
|
|
688
710
|
safe to click: any row that names a path that exists is a live control on
|
|
689
711
|
that path. Seed paths must be realistic in shape and impossible in fact.
|
|
712
|
+
|
|
713
|
+
## A width became 264px of height on every narrow row (2026-09-19)
|
|
714
|
+
|
|
715
|
+
- **What happened.** `.term-row > .term-body { flex-basis: 24ch }` gave the
|
|
716
|
+
prompt column a starting width on the wide board. Under 760px the row
|
|
717
|
+
stacks (`flex-direction: column`), the same declaration became a starting
|
|
718
|
+
HEIGHT, and every terminal row carried about 264px of empty space under its
|
|
719
|
+
last line. It shipped in 0.13.0 and survived three review passes because
|
|
720
|
+
each one eyeballed the 400px screenshot instead of measuring it.
|
|
721
|
+
- **Fix.** `flex-basis: auto` inside the narrow block. Narrow rows on the
|
|
722
|
+
seeded board went from 549/467/442/442 to 426/365/330/266.
|
|
723
|
+
- **The lesson that generalises.** A flex-basis is axis-relative; any rule
|
|
724
|
+
that sets one on a container whose direction flips at a breakpoint needs
|
|
725
|
+
the opposite value in that breakpoint. And the shot script's per-row
|
|
726
|
+
heights found it in one run when three visual reviews had not: the polish
|
|
727
|
+
loop is `_shot.mjs` numbers first, picture second (`.design/board-v5/`,
|
|
728
|
+
local only).
|
package/docs/board-guide.md
CHANGED
|
@@ -14,24 +14,23 @@ The visual system, and why it is what it is, is `DESIGN.md` at the repo root.
|
|
|
14
14
|
## Screenshots
|
|
15
15
|
|
|
16
16
|
`docs/screenshots/` (listed here so you know what exists before you look for
|
|
17
|
-
one). Nine were retaken on 2026-09-
|
|
18
|
-
`scripts/seed-wes-board.mjs
|
|
19
|
-
|
|
20
|
-
below:
|
|
17
|
+
one). Nine were retaken on 2026-09-19 against the polish pass (0.16.0), on a
|
|
18
|
+
board seeded by `scripts/seed-wes-board.mjs`; the rest are from the 2026-09-17
|
|
19
|
+
and 2026-09-15 sweeps and are noted below:
|
|
21
20
|
|
|
22
21
|
```
|
|
23
|
-
terminals-1280.png the whole board at 1280 px (2026-09-
|
|
24
|
-
board-400px.png the same board at 400 px (2026-09-
|
|
25
|
-
capacity-drawer-1280.png the strip with Capacity and models open (2026-09-
|
|
26
|
-
board-details-open.png a terminal row with its expansion open (2026-09-
|
|
27
|
-
board-handoff.png one row waiting on you, with the question (2026-09-
|
|
22
|
+
terminals-1280.png the whole board at 1280 px (2026-09-19)
|
|
23
|
+
board-400px.png the same board at 400 px (2026-09-19)
|
|
24
|
+
capacity-drawer-1280.png the strip with Capacity and models open (2026-09-19)
|
|
25
|
+
board-details-open.png a terminal row with its expansion open (2026-09-19)
|
|
26
|
+
board-handoff.png one row waiting on you, with the question (2026-09-19)
|
|
28
27
|
background-1280.png the Background panel and the one-line entry (2026-09-17)
|
|
29
|
-
settings-ladder-1280.png the ladder editor in Settings (2026-09-
|
|
30
|
-
board-empty.png no
|
|
28
|
+
settings-ladder-1280.png the ladder editor in Settings (2026-09-19)
|
|
29
|
+
board-empty.png the first-run panel: no terminal yet, three commands with Copy (2026-09-19)
|
|
31
30
|
board-running.png one card running its first agent (2026-09-17)
|
|
32
31
|
board-drawer.png that card expanded: where, runs, take over, timeline (2026-09-17)
|
|
33
32
|
board-done.png the card finished, on the agent that finished it
|
|
34
|
-
floor.png /floor with nothing queued
|
|
33
|
+
floor.png /floor with nothing queued, every station one line (2026-09-19)
|
|
35
34
|
floor-landing.png /floor with every lane full
|
|
36
35
|
floor-final-1280.png /floor after a card finished
|
|
37
36
|
floor-final-400.png the same at 400 px
|
|
@@ -42,18 +41,20 @@ demo-2-limit-hit.png limit hit, bundle written, the row waiting on you
|
|
|
42
41
|
demo-3-handoff-bundle.png the row expanded: bundle path, run signal, timeline
|
|
43
42
|
demo-4-codex-running.png leg 2 running on fake-codex
|
|
44
43
|
demo-5-done.png done, both legs on the chain
|
|
44
|
+
new-card-dialog.png More settings: the task and who runs it (2026-09-19)
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
The five `demo-*.png` are one run of the sequence in [DEMO.md](DEMO.md), all at
|
|
48
48
|
1280 px.
|
|
49
49
|
|
|
50
|
-
**Still stale, as of 2026-09-
|
|
51
|
-
`share-guest-1280.png`, `board-
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
**Still stale, as of 2026-09-19:** `share-owner-1280.png`,
|
|
51
|
+
`share-guest-1280.png`, `board-done.png`, `background-1280.png`,
|
|
52
|
+
`board-running.png`, `board-drawer.png`, `floor-landing.png`, the two
|
|
53
|
+
`floor-final-*.png` and the five `demo-*.png` predate the 0.16.0 polish pass:
|
|
54
|
+
their rows still show the 2x2 button grid, coloured login words and the old
|
|
55
|
+
mark, and the oldest of them show the pre-strip top of the board. What each
|
|
56
|
+
one documents is still true. Retake them with the commands below rather than
|
|
57
|
+
trusting the chrome in any of those pictures.
|
|
57
58
|
|
|
58
59
|
To retake one, seed a board with the shape a real one has and drive it to the
|
|
59
60
|
state the shot needs:
|
|
@@ -174,7 +175,7 @@ reset, the source, any wall and the age of the reading, the same sentence the
|
|
|
174
175
|
gauges carry. A login with no percentage is not a `meter` at all, because
|
|
175
176
|
`aria-valuenow` would have to be a number nobody measured.
|
|
176
177
|
|
|
177
|
-
**`Capacity and models
|
|
178
|
+
**`Capacity and models`**, the disclosure at the end of the strip, opens a drawer holding
|
|
178
179
|
the login panels unchanged, plus a rail of model chips on each panel head:
|
|
179
180
|
`fable 63%` for a measured bucket, `fable out until 9:14 PM` for a model at its
|
|
180
181
|
wall, and a bare model name where Leg has a name but no figure. A percentage is
|
|
@@ -307,16 +308,18 @@ been at it, and what you can do about it.
|
|
|
307
308
|
stops and fable continues from the bundle.`, or `from the conversation` when
|
|
308
309
|
that rung is one a `--resume` keeps. Confirming posts the hand-off with the
|
|
309
310
|
model on it; a rung that walled between the draw and the press comes back as
|
|
310
|
-
the server's own 409 sentence in the row. It is a link and not a
|
|
311
|
-
the
|
|
311
|
+
the server's own 409 sentence in the row. It is a link and not a fourth button:
|
|
312
|
+
the button row is the shipped shape and does not reflow.
|
|
312
313
|
- **The clock**: elapsed since the session started (`4h 24m`), and the session's
|
|
313
314
|
short id. The id used to print as `claude-7f3a` immediately after the word
|
|
314
315
|
`claude`; the prefix is the agent name twice and it is gone.
|
|
315
|
-
- **The buttons**,
|
|
316
|
-
place: Land, Hand off now, Details, End. Land is
|
|
317
|
-
it can actually run,
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
- **The buttons**, one row under the clock at the right, so every row's
|
|
317
|
+
controls sit in the same place: Land, Hand off now, Details, End. Land is
|
|
318
|
+
drawn only when it can actually run, and then it is the one primary button
|
|
319
|
+
on the board; when it cannot, the row does not draw it and its reason is
|
|
320
|
+
printed once above the panel, never left in a tooltip. Hand off now, Details
|
|
321
|
+
and End are quiet controls: the accent means "this can land" and nothing
|
|
322
|
+
else.
|
|
320
323
|
|
|
321
324
|
**A terminal waiting on a human says so, and the tab says it too.** Claude
|
|
322
325
|
Code's Notification hook writes `waiting` on the record when it puts up a
|
|
@@ -417,7 +420,8 @@ one off.`, because two waiters on one terminal is the failure to avoid.
|
|
|
417
420
|
### Terminal buttons
|
|
418
421
|
|
|
419
422
|
The order is fixed, Land, Hand off now, Details, End, and it never reflows by
|
|
420
|
-
availability: a button that does not apply is omitted, never moved.
|
|
423
|
+
availability: a button that does not apply is omitted, never moved. A Land that
|
|
424
|
+
cannot run is omitted too, with its reason hoisted above the panel.
|
|
421
425
|
|
|
422
426
|
| button | shown when | what it does |
|
|
423
427
|
|--------|------------|--------------|
|
|
@@ -555,7 +559,7 @@ raised surface here would compete with the terminals that are live. Each is a
|
|
|
555
559
|
heading, a line of detail and a button that opens the detail below the row.
|
|
556
560
|
|
|
557
561
|
- **N finished**, the terminals that have ended or been lost, `4 lost, 2 ended,
|
|
558
|
-
in leg, costclaw, declick`. **View
|
|
562
|
+
in leg, costclaw, declick`. **View** opens them as full rows. On a real
|
|
559
563
|
board after a day's work this is most of the list, which is exactly why it is a
|
|
560
564
|
count and not the list.
|
|
561
565
|
- **N landed**, what has landed on trunk across every repo the board can see,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ucsandman/legcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. 14-day free trial, then $79 once, 30-day money-back guarantee.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { appendFileSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { dirname, join } from 'node:path'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { releaseSection } from './release-notes.mjs'
|
|
10
11
|
|
|
11
12
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
13
|
const EXPECTED_REPOSITORY = 'https://github.com/ucsandman/legcli.git'
|
|
@@ -103,6 +104,9 @@ try {
|
|
|
103
104
|
if (alias.repository?.url !== EXPECTED_REPOSITORY) {
|
|
104
105
|
fail('alias package repository metadata does not match the npm trusted publisher binding')
|
|
105
106
|
}
|
|
107
|
+
// The GitHub release ci.yml creates after the publish is this section, so a
|
|
108
|
+
// version nobody wrote up in CHANGELOG.md never reaches npm either.
|
|
109
|
+
releaseSection(readFileSync(join(root, 'CHANGELOG.md'), 'utf8'), pkg.version)
|
|
106
110
|
|
|
107
111
|
const primaryPublished = await registryStatus(pkg.name, pkg.version)
|
|
108
112
|
const aliasPublished = await registryStatus(alias.name, alias.version)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// release-notes — the CHANGELOG.md section for one version, for the GitHub
|
|
3
|
+
// release CI creates right after it publishes that version to npm.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/release-notes.mjs 0.15.1 the section body (no heading)
|
|
6
|
+
// node scripts/release-notes.mjs 0.15.1 --title "Leg 0.15.1: <first sentence>"
|
|
7
|
+
//
|
|
8
|
+
// Exits 1 with a sentence when CHANGELOG.md has no `## <version> (` heading, so
|
|
9
|
+
// the publish gate refuses a version nobody wrote up.
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
|
+
|
|
16
|
+
export function releaseSection(changelog, version) {
|
|
17
|
+
const lines = changelog.split(/\r?\n/)
|
|
18
|
+
const heading = new RegExp(`^## ${version.replace(/\./g, '\\.')}(\\s|$)`)
|
|
19
|
+
const start = lines.findIndex((l) => heading.test(l))
|
|
20
|
+
if (start === -1) throw new Error(`CHANGELOG.md has no "## ${version}" section; write the release up before publishing it`)
|
|
21
|
+
let end = lines.findIndex((l, i) => i > start && /^## /.test(l))
|
|
22
|
+
if (end === -1) end = lines.length
|
|
23
|
+
const body = lines.slice(start + 1, end).join('\n').trim()
|
|
24
|
+
if (!body) throw new Error(`CHANGELOG.md's "## ${version}" section is empty`)
|
|
25
|
+
return body
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// "Leg <version>: <the section's first sentence, one line, no trailing stop>",
|
|
29
|
+
// capped so the release list stays readable. Older sections open with a
|
|
30
|
+
// bullet instead of a summary paragraph; the bullet's first sentence serves.
|
|
31
|
+
export function releaseTitle(changelog, version, { product = 'Leg', max = 100 } = {}) {
|
|
32
|
+
const body = releaseSection(changelog, version)
|
|
33
|
+
const paragraph = body.split(/\n\s*\n/)[0].replace(/^[-*]\s+/, '').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
|
|
34
|
+
const first = (paragraph.match(/^.*?[.!?](?=\s|$)/) ?? [paragraph])[0].replace(/[.!?:;,]$/, '')
|
|
35
|
+
let title = `${product} ${version}: ${first}`
|
|
36
|
+
if (title.length > max) title = title.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'
|
|
37
|
+
return title
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
41
|
+
const args = process.argv.slice(2)
|
|
42
|
+
const version = args.find((a) => !a.startsWith('--'))
|
|
43
|
+
if (!version) { console.error('usage: node scripts/release-notes.mjs <version> [--title]'); process.exit(2) }
|
|
44
|
+
try {
|
|
45
|
+
const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
|
|
46
|
+
process.stdout.write((args.includes('--title') ? releaseTitle(changelog, version) : releaseSection(changelog, version)) + '\n')
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error(e.message)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/board/board.css
CHANGED
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
|
|
136
136
|
/* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
|
|
137
137
|
is why it read as a spreadsheet */
|
|
138
|
-
--t--1: 0.
|
|
138
|
+
--t--1: 0.875rem; /* 14px the row register and the scaffolding strip */
|
|
139
139
|
--t-0: 0.9375rem; /* 15px labels, meta, controls, the narrow floor */
|
|
140
140
|
--t-1: 1.0625rem; /* 17px prompts, panel heads, body default */
|
|
141
141
|
--t-2: 1.3125rem; /* 21px section heads, secondary readings */
|
|
@@ -191,13 +191,21 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
191
191
|
.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)); }
|
|
192
192
|
|
|
193
193
|
.masthead { position: relative; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-block: 28px 24px; }
|
|
194
|
-
.brand { font-size: var(--t-1); font-weight: var(--w-head); letter-spacing: -.01em; }
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
|
|
200
|
-
.
|
|
194
|
+
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: var(--t-1); font-weight: var(--w-head); letter-spacing: -.01em; }
|
|
195
|
+
/* the mark: a tile with the L cut as hip, knee and foot. Inline SVG in the
|
|
196
|
+
markup, currentColor, so it is one shape on the board, the floor, the
|
|
197
|
+
favicon and the site */
|
|
198
|
+
.brand-glyph { display: inline-flex; width: 22px; height: 22px; color: var(--text); }
|
|
199
|
+
.brand-glyph svg { width: 22px; height: 22px; display: block; }
|
|
200
|
+
.masthead-right { display: flex; align-items: center; gap: 8px 24px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
|
|
201
|
+
/* connection and scheduler are one status cluster, read as one phrase */
|
|
202
|
+
.masthead-status { display: inline-flex; align-items: center; gap: 10px; color: var(--text-3); }
|
|
203
|
+
.masthead-status .sched-status:not(:empty)::before { content: ""; display: inline-block; width: 3px; height: 3px; border-radius: 999px; background: var(--line-strong); margin: 0 10px 3px 0; vertical-align: middle; }
|
|
204
|
+
/* the other page is a control, in the same clothes as every other quiet control */
|
|
205
|
+
.floor-link { display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border: 1px solid var(--control-edge); border-radius: var(--r-control); background: var(--e3); color: var(--text); font-weight: var(--w-head); text-decoration: none; transition: background-color 150ms var(--ease), border-color 150ms var(--ease); }
|
|
206
|
+
.floor-link:hover { background: var(--e4); border-color: #7D828A; color: var(--text); }
|
|
207
|
+
|
|
208
|
+
.sse-status { display: inline-flex; align-items: center; gap: 8px; flex-direction: row-reverse; }
|
|
201
209
|
.sse-word { color: var(--text-3); }
|
|
202
210
|
.sse-rule { width: 8px; height: 8px; border-radius: 999px; background: var(--ok); flex: none; }
|
|
203
211
|
.sse-rule.is-connecting { background: var(--warn); }
|
|
@@ -224,24 +232,21 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
224
232
|
/* the strip itself never wraps: the disclosure keeps the strip's own line at
|
|
225
233
|
the right (A.2) and the tokens wrap inside their own box, so four logins
|
|
226
234
|
that need two rows of tokens cost no third line for one button. */
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
/* the tokens are a small aligned table, dot, name, track, figure, so four
|
|
236
|
+
logins read as four rows of one instrument and not a ragged sentence. The
|
|
237
|
+
name is text: identity is the dot and the fill, never the colour of a word */
|
|
238
|
+
.capacity-strip { display: flex; align-items: flex-start; gap: 12px 32px; flex-wrap: nowrap; min-height: 44px; font-size: var(--t-0); }
|
|
239
|
+
.cap-tokens { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 10px 40px; flex: 1 1 auto; min-width: 0; }
|
|
240
|
+
.cap-token { display: grid; grid-template-columns: 8px 7ch 120px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 28px; }
|
|
230
241
|
.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
242
|
.cap-track { position: relative; flex: none; width: 120px; height: 6px; background: var(--track); border-radius: 999px; box-shadow: var(--sink); overflow: hidden; }
|
|
238
243
|
.cap-fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: var(--calm); }
|
|
239
|
-
.cap-figure { color: var(--text-
|
|
244
|
+
.cap-figure { color: var(--text-3); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
240
245
|
/* a login at its wall is the one place a word IS the failure, so it may carry
|
|
241
246
|
the colour; a percentage never does, the track carries that */
|
|
242
247
|
.cap-figure.is-out { color: var(--danger-text); }
|
|
243
|
-
.cap-figure--none { color: var(--text-3); }
|
|
244
|
-
.cap-open { margin-left: auto; flex: none; }
|
|
248
|
+
.cap-figure--none { color: var(--text-3); grid-column: 3 / -1; }
|
|
249
|
+
.cap-open { margin-left: auto; flex: none; margin-top: -4px; }
|
|
245
250
|
.capacity-drawer { margin-top: 16px; }
|
|
246
251
|
|
|
247
252
|
/* the model rail on a panel head inside the drawer: a measured percentage, or
|
|
@@ -297,12 +302,12 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
297
302
|
.reading-sub--lead { margin-top: 22px; }
|
|
298
303
|
|
|
299
304
|
/* ---- section heads ------------------------------------------------------ */
|
|
300
|
-
.section-head { padding-block:
|
|
305
|
+
.section-head { padding-block: 40px 14px; }
|
|
301
306
|
.section-head h2 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
|
|
302
|
-
.section-head .region-meta { margin-top: 8px; font-size: var(--t-0); color: var(--text-
|
|
307
|
+
.section-head .region-meta { margin-top: 8px; font-size: var(--t-0); color: var(--text-2); }
|
|
303
308
|
.section-head--quiet { padding-block: 56px 16px; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
|
|
304
309
|
.section-head--quiet .region-meta { margin-top: 0; }
|
|
305
|
-
.hoisted { margin-top:
|
|
310
|
+
.hoisted { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; font-size: var(--t-0); }
|
|
306
311
|
.hoisted-note { color: var(--text-2); }
|
|
307
312
|
.hoisted-note.tone-warn { color: var(--warn-text); }
|
|
308
313
|
.hoisted-note.tone-danger { color: var(--danger-text); }
|
|
@@ -310,44 +315,59 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
310
315
|
|
|
311
316
|
/* ---- terminals: rows inside one panel, separated by the only rule left --- */
|
|
312
317
|
.terms { padding: 4px 0; }
|
|
313
|
-
.term { padding:
|
|
318
|
+
.term { padding: 22px 28px; transition: background-color 150ms var(--ease); }
|
|
314
319
|
.term + .term { border-top: 1px solid var(--line); }
|
|
315
|
-
.term
|
|
316
|
-
|
|
320
|
+
.term:hover { background: rgba(255,255,255,.015); }
|
|
321
|
+
/* a row whose state just changed is lit for a moment, then settles: the one
|
|
322
|
+
motion on the board, and it answers what moved while the reader was away */
|
|
323
|
+
.term.is-changed { animation: settle 1400ms var(--ease) 1; }
|
|
324
|
+
@keyframes settle { 0% { background-color: rgba(92,145,255,.14); } 100% { background-color: transparent; } }
|
|
325
|
+
.term-row { display: flex; align-items: flex-start; gap: 32px; }
|
|
317
326
|
.term-body { flex: 1 1 auto; min-width: 0; }
|
|
318
327
|
.term-row > .term-body { flex-basis: 24ch; }
|
|
319
328
|
.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; }
|
|
320
|
-
.term-register { display: flex; align-items:
|
|
329
|
+
.term-register { display: flex; align-items: center; gap: 6px 14px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
|
|
321
330
|
.term-where { color: var(--text-2); }
|
|
322
331
|
/* the register's data tokens (A.4 rows 7 to 9). They are one line with the
|
|
323
332
|
status word, in the register's own muted tone: what changed and how long it
|
|
324
333
|
has been quiet are observations, not demands. The model token is the one
|
|
325
334
|
fact here a reader looks for by name, so it takes the identity colour and
|
|
326
335
|
the mono face the other machine strings on this page use. */
|
|
327
|
-
.term-dirty, .term-ahead, .term-quiet { color: var(--text-3); }
|
|
328
|
-
|
|
336
|
+
.term-dirty, .term-ahead, .term-quiet { color: var(--text-3); font-size: var(--t--1); }
|
|
337
|
+
/* the rung is a dot in the agent's colour beside its name in text, the same
|
|
338
|
+
grammar as the capacity strip; the chip-id class stays for the tests and the
|
|
339
|
+
dot, the colour no longer lands on the word */
|
|
340
|
+
.term-model { display: inline-flex; align-items: center; gap: 7px; color: var(--text-2); }
|
|
341
|
+
.term-model .dot { width: 7px; height: 7px; }
|
|
329
342
|
/* the files line and the row's capacity phrase share one line, the phrase at
|
|
330
343
|
the right (A.2). The phrase is per MODEL, which is a fact about this row;
|
|
331
344
|
the region head carries the share clause for the login. */
|
|
332
|
-
.term-meta { display: flex; align-items: baseline; justify-content:
|
|
333
|
-
.term-meta .files { flex:
|
|
345
|
+
.term-meta { display: flex; align-items: baseline; justify-content: flex-start; gap: 6px 24px; flex-wrap: wrap; margin-top: 6px; }
|
|
346
|
+
.term-meta .files { flex: 0 1 auto; min-width: 0; }
|
|
334
347
|
/* the phrase wraps rather than pushing the row sideways: with a forecast it is
|
|
335
348
|
`about 2h 40m of fable left, from 9 samples over 4h`, two and a half times
|
|
336
349
|
the percentage form, and a nowrap run that long overflows the 400px board. */
|
|
337
|
-
.term-capacity { margin-top:
|
|
350
|
+
.term-capacity { margin-top: 4px; flex: 0 1 auto; min-width: 0; display: inline-flex; align-items: baseline; flex-wrap: wrap; gap: 10px; font-size: var(--t--1); color: var(--text-3); }
|
|
338
351
|
/* the climb is a link beside the figure that explains it, never a fifth button
|
|
339
352
|
in the 2x2 grid, which is the shipped shape */
|
|
340
353
|
.term-climb { font-size: var(--t--1); }
|
|
341
354
|
/* the keyboard ring: a row the keys act on, marked on the edge the eye scans
|
|
342
355
|
down rather than by a fill that would compete with is-urgent */
|
|
343
356
|
.term.is-focused { box-shadow: inset 3px 0 0 var(--focus); }
|
|
344
|
-
.term-when { color: var(--text-3);
|
|
357
|
+
.term-when { color: var(--text-3); text-align: right; }
|
|
345
358
|
.term-id { color: var(--text-3); min-width: 4ch; text-align: right; font-family: var(--mono); }
|
|
346
|
-
|
|
347
|
-
.term-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
.term-actions .btn
|
|
359
|
+
/* the clock over the actions, both right-aligned, in one side column */
|
|
360
|
+
.term-side { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }
|
|
361
|
+
.term-clock { font-family: var(--mono); gap: 10px; }
|
|
362
|
+
.term-actions { flex: none; display: flex; justify-content: flex-end; gap: 8px; }
|
|
363
|
+
.term-actions .btn { white-space: nowrap; }
|
|
364
|
+
/* a control that cannot run is not drawn: its reason is hoisted to the region
|
|
365
|
+
head (the blocker paragraph stays in the DOM for aria-describedby) */
|
|
366
|
+
.term-actions .btn:disabled { display: none; }
|
|
367
|
+
/* End and Remove are quiet at rest; the colour comes on hover, and the
|
|
368
|
+
confirm row is where the decision is made */
|
|
369
|
+
.term-actions .btn-danger { color: var(--text-2); }
|
|
370
|
+
.term-actions .btn-danger:hover { color: var(--danger-text); }
|
|
351
371
|
|
|
352
372
|
.state { display: inline-flex; align-items: center; gap: 8px; color: var(--text-2); font-weight: var(--w-head); }
|
|
353
373
|
.mark { width: 8px; height: 8px; border-radius: 999px; flex: none; background: var(--text-3); }
|
|
@@ -360,16 +380,22 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
360
380
|
}
|
|
361
381
|
@keyframes breathe { 0%,100% { opacity: 1 } 50% { opacity: .45 } }
|
|
362
382
|
|
|
363
|
-
.term-prompt { margin-top:
|
|
383
|
+
.term-prompt { margin-top: 10px; font-size: var(--t-1); line-height: 1.45; max-width: 62ch; color: var(--text); }
|
|
364
384
|
.term-prompt--empty { color: var(--text-3); }
|
|
365
|
-
|
|
385
|
+
/* two lines of the prompt, then the expansion: a pasted brief is read in
|
|
386
|
+
Details, not in the list */
|
|
387
|
+
.panel-prompt { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; width: 100%; text-align: left; background: none; border: 0; padding: 0; margin-top: 10px; font-size: var(--t-1); line-height: 1.45; max-width: 64ch; color: var(--text); cursor: pointer; }
|
|
388
|
+
.panel-prompt[aria-expanded="true"] { -webkit-line-clamp: unset; }
|
|
366
389
|
.panel-prompt:hover { color: var(--accent-hi); }
|
|
367
390
|
|
|
368
|
-
.sentence { margin-top:
|
|
391
|
+
.sentence { margin-top: 8px; font-size: var(--t-0); color: var(--text-2); max-width: 64ch; }
|
|
369
392
|
.sentence.tone-warn { color: var(--warn-text); }
|
|
370
393
|
.sentence.tone-danger { color: var(--danger-text); }
|
|
371
394
|
.sentence.tone-ok { color: var(--ok-text); }
|
|
372
395
|
.sentence.tone-muted { color: var(--text-3); }
|
|
396
|
+
/* a warning sentence carries its colour only on the row that needs a person;
|
|
397
|
+
on a running row it is an observation, and an observation is not amber */
|
|
398
|
+
.term:not(.is-urgent) .sentence.tone-warn { color: var(--text-2); }
|
|
373
399
|
.files { margin-top: 10px; font-size: var(--t--1); color: var(--text-3); }
|
|
374
400
|
.file { color: var(--text-3); }
|
|
375
401
|
.file.is-overlap { color: var(--warn-text); }
|
|
@@ -440,11 +466,15 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
440
466
|
.drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
|
|
441
467
|
|
|
442
468
|
/* ---- ledger: on the ground, unpanelled --------------------------------- */
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
.ledger
|
|
446
|
-
.ledger-
|
|
447
|
-
.ledger-cell
|
|
469
|
+
/* four cells of one anatomy: a count, a line of meta, and the verb on one
|
|
470
|
+
baseline whatever the meta's length */
|
|
471
|
+
.ledger { margin-top: 48px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 32px 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
|
|
472
|
+
.ledger-cell { display: flex; flex-direction: column; }
|
|
473
|
+
.ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; line-height: 1.2; }
|
|
474
|
+
.ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
|
|
475
|
+
.ledger-cell .region-meta:empty { display: none; }
|
|
476
|
+
.ledger-actions { margin-top: auto; padding-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
477
|
+
.ledger-cell .empty-line { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
|
|
448
478
|
|
|
449
479
|
.list { margin-top: 20px; }
|
|
450
480
|
.list li { padding: 14px 0; font-size: var(--t-0); }
|
|
@@ -528,7 +558,9 @@ code { font-family: var(--mono); font-size: 0.94em; }
|
|
|
528
558
|
.row-meta { font-size: var(--t-0); color: var(--text-3); display: flex; gap: 14px; flex-wrap: wrap; }
|
|
529
559
|
.row-actions { margin-top: 8px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
530
560
|
/* the demoted-notes disclosure in a terminal row */
|
|
531
|
-
.also { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: var(--t
|
|
561
|
+
.also { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
|
|
562
|
+
.term .also { margin-top: 2px; min-height: 28px; font-weight: var(--w-text); text-decoration-color: var(--line-strong); }
|
|
563
|
+
.term .also:hover { color: var(--text-2); }
|
|
532
564
|
.status-word { font-weight: var(--w-head); color: var(--text-2); }
|
|
533
565
|
.elapsed { font-family: var(--mono); color: var(--text-3); }
|
|
534
566
|
.where { color: var(--text-2); }
|
|
@@ -671,10 +703,10 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
671
703
|
120px of instrument costs the percentage its own line, and the percentage
|
|
672
704
|
with its state word carries the whole fact on its own */
|
|
673
705
|
.capacity-strip { display: block; }
|
|
674
|
-
.cap-tokens {
|
|
675
|
-
.cap-token {
|
|
706
|
+
.cap-tokens { grid-template-columns: 1fr; }
|
|
707
|
+
.cap-token { grid-template-columns: 8px 7ch minmax(0, 1fr); }
|
|
676
708
|
.cap-track { display: none; }
|
|
677
|
-
.cap-figure {
|
|
709
|
+
.cap-figure { grid-column: 3; text-align: right; }
|
|
678
710
|
.cap-open { margin-left: 0; margin-top: 12px; }
|
|
679
711
|
.panel { padding: 22px; border-radius: 14px; }
|
|
680
712
|
.logins-pair { grid-template-columns: 1fr; }
|
|
@@ -689,16 +721,26 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
689
721
|
code { font-size: 1em; }
|
|
690
722
|
.section-head { padding-block: 24px 16px; }
|
|
691
723
|
.section-head--quiet { padding-block: 24px 16px; }
|
|
692
|
-
.term { padding:
|
|
693
|
-
.term-row { flex-direction: column; align-items: stretch; gap:
|
|
694
|
-
|
|
724
|
+
.term { padding: 20px; }
|
|
725
|
+
.term-row { flex-direction: column; align-items: stretch; gap: 12px; }
|
|
726
|
+
/* the 24ch basis is a width in the row direction; stacked, it became 264px
|
|
727
|
+
of empty height under every row */
|
|
728
|
+
.term-row > .term-body { flex-basis: auto; }
|
|
729
|
+
/* the side column dissolves: the clock goes above the register and the
|
|
730
|
+
actions below the body, both in the row's own flow */
|
|
731
|
+
.term-side { display: contents; }
|
|
732
|
+
.term-clock { order: -1; width: auto; justify-content: flex-end; gap: 10px; }
|
|
733
|
+
.term-actions { order: 1; justify-content: flex-start; flex-wrap: wrap; }
|
|
695
734
|
.term-id { min-width: 0; }
|
|
696
|
-
.term-actions { width: auto; justify-content: flex-start; }
|
|
697
|
-
.term-actions .btn { flex: 1 1 auto; }
|
|
698
735
|
.term-register { gap: 10px 14px; }
|
|
699
736
|
.kv { grid-template-columns: 1fr; gap: 4px 0; }
|
|
700
737
|
.kv-val + .kv-key { margin-top: 12px; }
|
|
701
738
|
.ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
|
|
739
|
+
.region-settings .section-head--quiet .disclosure { margin-left: 0; }
|
|
740
|
+
#card-entry { padding: 18px 20px; }
|
|
741
|
+
#card-entry .entry-opts .btn-text { margin-left: 0; }
|
|
742
|
+
.region-floor.is-empty { grid-template-columns: 1fr; gap: 6px; }
|
|
743
|
+
.first-run { padding: 22px; }
|
|
702
744
|
.history-row { grid-template-columns: 1fr; }
|
|
703
745
|
.history-filters { grid-template-columns: 1fr; }
|
|
704
746
|
.line { grid-template-columns: 1fr auto; gap: 6px 14px; }
|
|
@@ -711,6 +753,7 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
|
|
|
711
753
|
|
|
712
754
|
@media (prefers-reduced-motion: reduce) {
|
|
713
755
|
.mark.state-active { animation: none; }
|
|
756
|
+
.term.is-changed { animation: none; }
|
|
714
757
|
* { transition: none !important; }
|
|
715
758
|
.btn:active { transform: none; }
|
|
716
759
|
}
|
|
@@ -772,6 +815,7 @@ dialog { max-height: calc(100vh - 40px); overflow-y: auto; }
|
|
|
772
815
|
rather than in the page's own ground. The board's copy is inside the
|
|
773
816
|
Background panel and inherits its surface; this one has none to inherit. */
|
|
774
817
|
.floor-entry { margin-top: 20px; background: var(--e2); border: 1px solid var(--edge); border-radius: var(--r-well); padding: 20px 24px; }
|
|
818
|
+
.floor-entry .confirm-row { background: none; padding: 0; }
|
|
775
819
|
.floor-entry .confirm-row { margin-top: 0; }
|
|
776
820
|
.floor-entry .confirm-row input[type="text"] { flex: 1 1 320px; min-width: 0; }
|
|
777
821
|
.floor-entry .field-help { margin-top: 10px; }
|
|
@@ -798,3 +842,81 @@ dialog { max-height: calc(100vh - 40px); overflow-y: auto; }
|
|
|
798
842
|
a label with no control focuses nothing when it is clicked, which is the one
|
|
799
843
|
thing a label promises. Same face as the row labels beside it. */
|
|
800
844
|
.form-row .form-label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
|
|
845
|
+
|
|
846
|
+
/* ---- 2026-09-19: the polish pass (.design/board-v5) --------------------- */
|
|
847
|
+
/* A disclosure says where it opens with a chevron drawn here, not a typed > */
|
|
848
|
+
.disclosure::after { content: ""; display: inline-block; width: 7px; height: 7px; margin-left: 10px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform 150ms var(--ease); }
|
|
849
|
+
.disclosure[aria-expanded="true"]::after { transform: translateY(1px) rotate(-135deg); }
|
|
850
|
+
|
|
851
|
+
/* The verdict names a row; the sentence is the way to that row. A button in
|
|
852
|
+
the h1's own clothes: no underline, the accent only on hover and focus. */
|
|
853
|
+
.verdict-link { font: inherit; letter-spacing: inherit; color: inherit; background: none; border: 0; padding: 0; text-align: left; cursor: pointer; text-wrap: inherit; }
|
|
854
|
+
.verdict-link:hover { color: var(--accent-hi); }
|
|
855
|
+
|
|
856
|
+
/* The background entry: on the board it is the terminals panel's own footer
|
|
857
|
+
(a sibling, joined to the panel above it); under the Background panel, and
|
|
858
|
+
on the floor, it is a well of its own. Its choices are quiet chips inside
|
|
859
|
+
the sentence, and More settings stays the one text link. */
|
|
860
|
+
#card-entry { margin-top: 12px; background: var(--e1); border: 1px solid var(--edge); border-top-color: var(--line); border-radius: var(--r-well); padding: 20px 28px 18px; box-shadow: var(--sink); }
|
|
861
|
+
/* entry.js parks the entry right after the Terminals section; when the panel
|
|
862
|
+
above it has rows the two join into one object, and an empty panel is not
|
|
863
|
+
drawn at all */
|
|
864
|
+
#session-grid:empty { display: none; }
|
|
865
|
+
.region-terminals:has(#session-grid:not(:empty)):has(+ #card-entry:not([hidden])) #session-grid { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
|
866
|
+
.region-terminals:has(#session-grid:not(:empty)) + #card-entry { border-radius: 0 0 var(--r-panel) var(--r-panel); margin-top: -1px; }
|
|
867
|
+
#background #card-entry { margin-top: 14px; }
|
|
868
|
+
#card-entry .confirm-row { margin: 0; background: none; padding: 0; gap: 12px; }
|
|
869
|
+
#card-entry .confirm-row > span:first-child { font-weight: var(--w-head); color: var(--text-2); }
|
|
870
|
+
#card-entry .confirm-row input[type="text"] { flex: 1 1 280px; min-width: 0; background: var(--e0); }
|
|
871
|
+
#card-entry .confirm-row .field-help { flex: 1 0 100%; margin: 0; }
|
|
872
|
+
#card-entry .entry-opts { margin-top: 12px; display: flex; align-items: center; gap: 8px 6px; flex-wrap: wrap; font-size: var(--t-0); color: var(--text-3); }
|
|
873
|
+
#card-entry .entry-opts .btn-sm { min-height: 30px; font-weight: var(--w-text); color: var(--text-2); background: var(--e2); border-color: var(--edge-hi); }
|
|
874
|
+
#card-entry .entry-opts .btn-sm:hover { color: var(--text); background: var(--e3); }
|
|
875
|
+
#card-entry .entry-opts .btn-text { min-height: 30px; }
|
|
876
|
+
#card-entry .entry-opts select { font-size: var(--t--1); min-height: 30px; padding: 4px 8px; }
|
|
877
|
+
|
|
878
|
+
/* First run. A board with no terminal is the first thing a new reader sees,
|
|
879
|
+
so it is one lit panel with the three commands and a Copy each, not a grey
|
|
880
|
+
sentence. sessions.js hides it the moment a terminal is live. */
|
|
881
|
+
.first-run { margin-top: 0; padding: 28px 32px; }
|
|
882
|
+
.first-run h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
|
|
883
|
+
.first-run p { margin-top: 8px; font-size: var(--t-0); color: var(--text-2); max-width: 56ch; }
|
|
884
|
+
.first-run-cmds { margin-top: 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
|
|
885
|
+
.first-run-cmd { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--e0); border: 1px solid var(--edge); border-radius: var(--r-control); padding: 10px 10px 10px 16px; }
|
|
886
|
+
.first-run-cmd code { font-size: var(--t-0); color: var(--text); }
|
|
887
|
+
.first-run-cmd .dot { margin-right: 10px; }
|
|
888
|
+
.first-run-note { margin-top: 16px; font-size: var(--t--1); color: var(--text-3); }
|
|
889
|
+
|
|
890
|
+
/* Settings is the last ledger row, not a stray sentence with a button */
|
|
891
|
+
.region-settings .section-head--quiet { margin-top: 40px; padding-block: 24px 0; border-top: 1px solid var(--edge); align-items: center; }
|
|
892
|
+
.region-settings .section-head--quiet h2 { font-size: var(--t-2); }
|
|
893
|
+
.region-settings .section-head--quiet .region-meta { flex: 1 1 auto; }
|
|
894
|
+
.region-settings .section-head--quiet .disclosure { margin-left: auto; }
|
|
895
|
+
|
|
896
|
+
/* The floor: an empty station is one line, heading and sentence side by side,
|
|
897
|
+
so seven empty stations are seven lines and not seven screens. A station
|
|
898
|
+
with rows keeps its full section. floor.js sets is-empty. */
|
|
899
|
+
.region-floor.is-empty { display: grid; grid-template-columns: 16ch minmax(0, 1fr); gap: 8px 24px; align-items: baseline; padding: 18px 0; margin-top: 0; border-top: 1px solid var(--edge); }
|
|
900
|
+
.region-floor.is-empty .section-head { padding: 0; display: flex; align-items: baseline; gap: 6px 12px; flex-wrap: wrap; }
|
|
901
|
+
.region-floor.is-empty .section-head h2 { font-size: var(--t-1); white-space: nowrap; }
|
|
902
|
+
.region-floor.is-empty .section-head--quiet .btn { margin-left: 4px; min-height: 30px; }
|
|
903
|
+
.region-floor.is-empty .region-meta:empty, .region-floor.is-empty .card-list:empty, .region-floor.is-empty .ftable { display: none; }
|
|
904
|
+
.region-floor.is-empty .empty-line { margin: 0; color: var(--text-3); }
|
|
905
|
+
.region-floor:not(.is-empty) + .region-floor.is-empty { margin-top: 24px; }
|
|
906
|
+
.masthead-left { font-size: var(--t-0); }
|
|
907
|
+
/* ---- end of the 2026-09-19 block ---------------------------------------- */
|
|
908
|
+
|
|
909
|
+
/* ---- 2026-09-19, second pass: what the rendered seed showed ------------- */
|
|
910
|
+
/* the rung word and the ladder's rung names are text: the chip-id class was
|
|
911
|
+
winning on order, and the identity dot beside each one already says who */
|
|
912
|
+
.term-model[class*="chip-id-"], .ladder-name[class*="chip-id-"] { color: var(--text-2); }
|
|
913
|
+
/* the cards cell's verb sits on the baseline the other cells share, whatever
|
|
914
|
+
order its markup arrives in */
|
|
915
|
+
.ledger-cell .ledger-actions { order: 9; }
|
|
916
|
+
/* a number field wears the same clothes as a text field */
|
|
917
|
+
input[type="number"] { 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; width: 7ch; }
|
|
918
|
+
/* a finished row has no Land, so its land blocker is read by nothing on screen */
|
|
919
|
+
#finished-list .blocker { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
920
|
+
/* the entry's help line sits after Start on the same row */
|
|
921
|
+
#card-entry .confirm-row .field-help { flex: 0 1 auto; }
|
|
922
|
+
/* ---- end of the second pass ----------------------------------------------- */
|
package/src/board/board.js
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
// release lands on disk under a board that was started before it: the page
|
|
69
69
|
// then draws controls the process has no routes for (an empty agent select,
|
|
70
70
|
// no buckets). test/files-version.test.mjs pins this to package.json.
|
|
71
|
-
const FILES_VERSION = '0.
|
|
71
|
+
const FILES_VERSION = '0.16.0'
|
|
72
72
|
const TIMELINE_CAP = 12
|
|
73
73
|
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
74
74
|
// host arrives bracketed
|
|
@@ -1744,7 +1744,10 @@
|
|
|
1744
1744
|
initSettings()
|
|
1745
1745
|
initNewCardDialog()
|
|
1746
1746
|
document.getElementById('new-card-btn').addEventListener('click', () => openNewCardDialog())
|
|
1747
|
-
|
|
1747
|
+
// first run: each command has a Copy, the same clipboard path the drawer uses
|
|
1748
|
+
for (const b of document.querySelectorAll('#first-run [data-copy]')) {
|
|
1749
|
+
b.addEventListener('click', () => { copyToClipboard(b.getAttribute('data-copy')); b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy' }, 1400) })
|
|
1750
|
+
}
|
|
1748
1751
|
document.getElementById('drawer-close').addEventListener('click', () => collapseRow())
|
|
1749
1752
|
document.addEventListener('keydown', (e) => {
|
|
1750
1753
|
if (e.key !== 'Escape') return
|
package/src/board/entry.js
CHANGED
|
@@ -234,7 +234,7 @@
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
function nounButton(text, key) {
|
|
237
|
-
return el('button', { type: 'button', class: 'btn btn-
|
|
237
|
+
return el('button', { type: 'button', class: 'btn btn-secondary btn-sm entry-noun', onclick: () => { entryState.editing = key; renderEntryLine() } }, [text])
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
function renderEntryLine() {
|
|
@@ -262,7 +262,7 @@
|
|
|
262
262
|
// flex row with a gap on the raised surface, which is exactly this line.
|
|
263
263
|
box.appendChild(el('div', { class: 'confirm-row' }, [el('span', {}, ['Run in the background:']), task, start, reason]))
|
|
264
264
|
|
|
265
|
-
const line = el('p', { class: 'field-help' })
|
|
265
|
+
const line = el('p', { class: 'field-help entry-opts' })
|
|
266
266
|
line.appendChild(document.createTextNode('in '))
|
|
267
267
|
if (entryState.editing === 'repo') {
|
|
268
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 }))
|
|
@@ -297,13 +297,13 @@
|
|
|
297
297
|
// the sentence names the legs Start posts, models and all
|
|
298
298
|
line.appendChild(nounButton(ladderSentence(entryChain()), 'ladder'))
|
|
299
299
|
}
|
|
300
|
-
line.appendChild(document.createTextNode('
|
|
300
|
+
line.appendChild(document.createTextNode(' '))
|
|
301
301
|
if (entryState.editing === 'pipeline') {
|
|
302
302
|
line.appendChild(nounSelect(Object.keys(PIPELINE_WORDS).map((v) => ({ value: v, label: PIPELINE_WORDS[v] })), entryState.pipeline, (v) => { entryState.pipeline = v }))
|
|
303
303
|
} else {
|
|
304
304
|
line.appendChild(nounButton(PIPELINE_WORDS[entryState.pipeline] || 'build only', 'pipeline'))
|
|
305
305
|
}
|
|
306
|
-
line.appendChild(document.createTextNode('
|
|
306
|
+
line.appendChild(document.createTextNode(' '))
|
|
307
307
|
line.appendChild(el('button', { type: 'button', class: 'btn btn-text', onclick: () => onMoreSettings(entryState) }, ['More settings']))
|
|
308
308
|
box.appendChild(line)
|
|
309
309
|
}
|
package/src/board/favicon.svg
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#F3F4F7"/><path d="M8 5.5v8.5l4 4h5" stroke="#0E1012" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>
|
package/src/board/floor.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<header class="masthead wrap">
|
|
13
13
|
<div class="masthead-left">
|
|
14
|
-
<h1 class="brand"><span class="brand-glyph" aria-hidden="true"
|
|
14
|
+
<h1 class="brand"><span class="brand-glyph" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6" fill="currentColor"/><path d="M8 5.5v8.5l4 4h5" stroke="#0E1012" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></span> Leg floor</h1>
|
|
15
15
|
<!-- the connection state sits second, immediately after the brand, the same
|
|
16
16
|
place it sits on the board: the two bars are read as one bar. -->
|
|
17
17
|
<div class="sse-status" role="status">
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
<section class="capacity" aria-label="Capacity by login">
|
|
44
44
|
<div class="capacity-strip">
|
|
45
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
|
|
46
|
+
<button type="button" class="btn btn-secondary disclosure cap-open" id="capacity-toggle" aria-expanded="false" aria-controls="capacity-drawer">Capacity and models</button>
|
|
47
47
|
</div>
|
|
48
48
|
<div class="drawer capacity-drawer" id="capacity-drawer" hidden>
|
|
49
49
|
<section class="logins" id="floor-accounts" aria-label="Logins"></section>
|
package/src/board/floor.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
// name. test/board-updates.test.mjs pins `state.stopped` in this file by
|
|
51
51
|
// source text, which is why the module object keeps the name.
|
|
52
52
|
// pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
|
|
53
|
-
const FILES_VERSION = '0.
|
|
53
|
+
const FILES_VERSION = '0.16.0'
|
|
54
54
|
const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
|
|
55
55
|
|
|
56
56
|
// What the entry row reads on every render: the terminals (for the repo it
|
|
@@ -443,6 +443,8 @@
|
|
|
443
443
|
const table = body.parentElement
|
|
444
444
|
const section = table && table.parentElement
|
|
445
445
|
if (section) { const old = section.querySelector('.empty-line'); if (old) old.remove() }
|
|
446
|
+
// an empty station is one line (board.css .region-floor.is-empty)
|
|
447
|
+
if (section && section.classList) section.classList.toggle('is-empty', !list.length)
|
|
446
448
|
const thead = table && table.querySelector('thead')
|
|
447
449
|
if (!list.length) {
|
|
448
450
|
if (thead) thead.hidden = true
|
|
@@ -747,6 +749,7 @@
|
|
|
747
749
|
const count = document.getElementById(station.count)
|
|
748
750
|
if (count) count.textContent = String(cards.length)
|
|
749
751
|
if (section && section.querySelector) { const old = section.querySelector('.empty-line'); if (old && old.remove) old.remove() }
|
|
752
|
+
if (section && section.classList) section.classList.toggle('is-empty', !cards.length)
|
|
750
753
|
if (!cards.length) {
|
|
751
754
|
box.textContent = ''
|
|
752
755
|
box.signature = ''
|
package/src/board/history.js
CHANGED
|
@@ -304,7 +304,7 @@
|
|
|
304
304
|
? `${byAgent}${filtered ? `; ${state.total} match the filters` : ''}${state.refreshedAt ? `; looked ${whenAgo(state.refreshedAt)}` : ''}`
|
|
305
305
|
: 'Leg looks in the Claude Code, Codex, Grok, Antigravity and Copilot homes on this machine, and its own sessions.'
|
|
306
306
|
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': state.open ? 'true' : 'false', 'aria-controls': 'history-drawer', 'data-focus-key': 'history-toggle' },
|
|
307
|
-
[state.open ? 'Hide
|
|
307
|
+
[state.open ? 'Hide' : 'Browse'])
|
|
308
308
|
btn.addEventListener('click', () => { state.open = !state.open; if (state.open && state.worktrees === null) loadWorktrees(); render() })
|
|
309
309
|
slot.appendChild(btn)
|
|
310
310
|
panel.hidden = !state.open
|
package/src/board/index.html
CHANGED
|
@@ -18,13 +18,15 @@
|
|
|
18
18
|
<div class="wrap">
|
|
19
19
|
|
|
20
20
|
<header class="masthead">
|
|
21
|
-
<p class="brand"><span class="brand-glyph" aria-hidden="true"
|
|
21
|
+
<p class="brand"><span class="brand-glyph" aria-hidden="true"><svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6" fill="currentColor"/><path d="M8 5.5v8.5l4 4h5" stroke="#0E1012" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></span> Leg</p>
|
|
22
22
|
<div class="masthead-right">
|
|
23
|
-
<div class="
|
|
24
|
-
<
|
|
25
|
-
|
|
23
|
+
<div class="masthead-status">
|
|
24
|
+
<div class="sse-status" role="status">
|
|
25
|
+
<span class="sse-word" id="sse-text">connecting</span>
|
|
26
|
+
<span class="sse-rule is-connecting" id="sse-dot" aria-hidden="true"></span>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="sched-status" id="sched-status"></div>
|
|
26
29
|
</div>
|
|
27
|
-
<div class="sched-status" id="sched-status"></div>
|
|
28
30
|
<a class="floor-link" href="/floor">Floor</a>
|
|
29
31
|
</div>
|
|
30
32
|
</header>
|
|
@@ -46,7 +48,7 @@
|
|
|
46
48
|
<section class="capacity" aria-label="Capacity by login">
|
|
47
49
|
<div class="capacity-strip">
|
|
48
50
|
<div class="cap-tokens" id="capacity-tokens"></div>
|
|
49
|
-
<button type="button" class="btn btn-secondary disclosure cap-open" id="capacity-toggle" aria-expanded="false" aria-controls="capacity-drawer">Capacity and models
|
|
51
|
+
<button type="button" class="btn btn-secondary disclosure cap-open" id="capacity-toggle" aria-expanded="false" aria-controls="capacity-drawer">Capacity and models</button>
|
|
50
52
|
</div>
|
|
51
53
|
<div class="drawer capacity-drawer" id="capacity-drawer" hidden>
|
|
52
54
|
<section class="logins" id="accounts" aria-label="Logins"></section>
|
|
@@ -67,7 +69,19 @@
|
|
|
67
69
|
<span class="chip" id="whoami" hidden></span>
|
|
68
70
|
</div>
|
|
69
71
|
<div class="panel terms" id="session-grid"></div>
|
|
70
|
-
|
|
72
|
+
<!-- first run: the board with no terminal is the first screen a new reader
|
|
73
|
+
sees, so it is a lit panel with the commands and a Copy each. sessions.js
|
|
74
|
+
hides it the moment a terminal is live; board.js wires Copy. -->
|
|
75
|
+
<div class="panel panel--lit first-run" id="first-run" hidden>
|
|
76
|
+
<h3>No terminal is running</h3>
|
|
77
|
+
<p>Start one in any repo with the same command you use today, and Leg runs it with the board alongside. It appears here within a second of the agent's first turn.</p>
|
|
78
|
+
<div class="first-run-cmds">
|
|
79
|
+
<div class="first-run-cmd"><span><span class="dot id-claude" aria-hidden="true"></span><code>leg claude</code></span><button type="button" class="btn btn-secondary btn-sm" data-copy="leg claude" aria-label="Copy leg claude">Copy</button></div>
|
|
80
|
+
<div class="first-run-cmd"><span><span class="dot id-codex" aria-hidden="true"></span><code>leg codex</code></span><button type="button" class="btn btn-secondary btn-sm" data-copy="leg codex" aria-label="Copy leg codex">Copy</button></div>
|
|
81
|
+
<div class="first-run-cmd"><span><span class="dot id-agy" aria-hidden="true"></span><code>leg agy</code></span><button type="button" class="btn btn-secondary btn-sm" data-copy="leg agy" aria-label="Copy leg agy">Copy</button></div>
|
|
82
|
+
</div>
|
|
83
|
+
<p class="first-run-note">Usage per login, the handoff ladder and the context bundle all start with the first terminal.</p>
|
|
84
|
+
</div>
|
|
71
85
|
</section>
|
|
72
86
|
|
|
73
87
|
<!-- C.1: a card is a terminal you are not sitting at. Live cards are rows
|
|
@@ -118,7 +132,7 @@
|
|
|
118
132
|
<div class="ledger-actions">
|
|
119
133
|
<button type="button" class="btn btn-secondary" id="new-card-btn">New card</button>
|
|
120
134
|
</div>
|
|
121
|
-
<p class="empty-line" id="empty-state" hidden>A card runs an agent headless in its own worktree
|
|
135
|
+
<p class="empty-line" id="empty-state" hidden>A card runs an agent headless in its own worktree.</p>
|
|
122
136
|
</div>
|
|
123
137
|
|
|
124
138
|
</section>
|
package/src/board/sessions.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
const alsoOpen = new Set()
|
|
51
51
|
const actionNotes = new Map()
|
|
52
52
|
const lastTone = new Map()
|
|
53
|
+
const rowShape = new Map()
|
|
53
54
|
const defaultEditor = { ladder: null, climb_back: 'next-handoff', may_spend: false, reserve: {}, dirty: false, saving: false, status: '', statusClass: '' }
|
|
54
55
|
|
|
55
56
|
// localStorage THROWS rather than answering null in a browser with site data
|
|
@@ -635,6 +636,19 @@
|
|
|
635
636
|
// `card 3e1c`: the tail of the card id, which is how a card is named on its
|
|
636
637
|
// own row and out loud.
|
|
637
638
|
function cardName(id) { return `card ${String(id).split('-').filter(Boolean).slice(-1)[0] || id}` }
|
|
639
|
+
// the terminal the verdict is about when a human is blocked: the one that
|
|
640
|
+
// has waited longest. verdictLines prints it; verdictTarget names its row.
|
|
641
|
+
function blockedOf(live) {
|
|
642
|
+
return live
|
|
643
|
+
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
644
|
+
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
645
|
+
}
|
|
646
|
+
function verdictTarget(list, sessions) {
|
|
647
|
+
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
648
|
+
if (!accounts.length) return null
|
|
649
|
+
const b = blockedOf((sessions || []).filter((s) => s.active))
|
|
650
|
+
return b ? b.session_id : null
|
|
651
|
+
}
|
|
638
652
|
function verdictLines(list, sessions, cards) {
|
|
639
653
|
const accounts = (list || []).filter((a) => a && a.agent && !a.loading)
|
|
640
654
|
if (!accounts.length) return { line: 'Reading the logins.', sub: '' }
|
|
@@ -668,9 +682,7 @@
|
|
|
668
682
|
// the TYPE is checked and never the presence of `since`. `quota_auto_resume`
|
|
669
683
|
// is left out here too: nobody asked the human anything, Claude Code is
|
|
670
684
|
// holding its own turn, and the row says so in its own sentence.
|
|
671
|
-
const blocked = live
|
|
672
|
-
.filter((s) => notifyWait(s) && s.waiting.type !== 'quota_auto_resume' && Number.isFinite(Date.parse(s.waiting.since)))
|
|
673
|
-
.sort((x, y) => Date.parse(x.waiting.since) - Date.parse(y.waiting.since))[0]
|
|
685
|
+
const blocked = blockedOf(live)
|
|
674
686
|
if (blocked) {
|
|
675
687
|
const who = rowName(blocked)
|
|
676
688
|
const waited = spoken(Date.now() - Date.parse(blocked.waiting.since))
|
|
@@ -881,7 +893,23 @@
|
|
|
881
893
|
const { line, sub } = verdictLines(list, view ? view.sessions : [], view ? view.cards_waiting : null)
|
|
882
894
|
const h1 = document.getElementById('verdict-line')
|
|
883
895
|
const p = document.getElementById('verdict-sub')
|
|
884
|
-
|
|
896
|
+
const target = verdictTarget(list, view ? view.sessions : [])
|
|
897
|
+
if (h1) {
|
|
898
|
+
// the sentence names a row, so the sentence is the way to that row: a
|
|
899
|
+
// button in the h1's own clothes that scrolls to the row and hands it the
|
|
900
|
+
// keyboard (the prompt is the row's first control)
|
|
901
|
+
if (target && typeof h1.replaceChildren === 'function') {
|
|
902
|
+
const link = el('button', { type: 'button', class: 'verdict-link', title: 'go to this terminal' }, [line])
|
|
903
|
+
link.addEventListener('click', () => {
|
|
904
|
+
const row = document.querySelector(`#session-grid .term[data-session-id="${target}"]`)
|
|
905
|
+
if (!row) return
|
|
906
|
+
if (typeof row.scrollIntoView === 'function') row.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
907
|
+
const first = row.querySelector('.panel-prompt, .btn')
|
|
908
|
+
if (first && typeof first.focus === 'function') first.focus({ preventScroll: true })
|
|
909
|
+
})
|
|
910
|
+
h1.replaceChildren(link)
|
|
911
|
+
} else h1.textContent = line
|
|
912
|
+
}
|
|
885
913
|
if (p) p.textContent = sub
|
|
886
914
|
// the strip is the only usage on screen until the reader opens the drawer
|
|
887
915
|
capacityStrip(list)
|
|
@@ -1717,7 +1745,14 @@
|
|
|
1717
1745
|
const urgent = needsYou(s, notes)
|
|
1718
1746
|
// the article is named so the accessibility tree does not hand the reader
|
|
1719
1747
|
// three identical triples of Land / Hand off now / Details / End
|
|
1720
|
-
|
|
1748
|
+
// what the row says, in one string: when it differs from the last render
|
|
1749
|
+
// the row is lit for a moment (board.css .term.is-changed), the one answer
|
|
1750
|
+
// the board gives to what moved while the reader was away
|
|
1751
|
+
const shape = `${s.status}|${urgent}|${notes[0] ? notes[0].cat : ''}|${promptText(s.task)}`
|
|
1752
|
+
const was = rowShape.get(s.session_id)
|
|
1753
|
+
rowShape.set(s.session_id, shape)
|
|
1754
|
+
const changed = was !== undefined && was !== shape
|
|
1755
|
+
const term = el('article', { class: `term${urgent ? ' is-urgent' : ''}${changed ? ' is-changed' : ''}`, 'data-session-id': s.session_id, 'aria-label': `${s.agent} ${tail(s.session_id)}` })
|
|
1721
1756
|
const row = el('div', { class: 'term-row' })
|
|
1722
1757
|
const body = el('div', { class: 'term-body' })
|
|
1723
1758
|
|
|
@@ -1733,7 +1768,7 @@
|
|
|
1733
1768
|
// which model actually answered, and whether it has gone quiet. The model
|
|
1734
1769
|
// token is the agent and the model it resolved to, never a default.
|
|
1735
1770
|
for (const t of registerTokens(s)) {
|
|
1736
|
-
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [t.text]))
|
|
1771
|
+
if (t.kind === 'model') register.appendChild(el('span', { class: `term-model chip-id-${idOf(s.agent)}` }, [el('span', { class: `dot id-${idOf(s.agent)}`, 'aria-hidden': 'true' }), t.text]))
|
|
1737
1772
|
else register.appendChild(el('span', { class: `term-${t.kind}` }, [t.text]))
|
|
1738
1773
|
}
|
|
1739
1774
|
if (s.account !== 'default') register.appendChild(el('span', { class: 'chip' }, [s.account]))
|
|
@@ -1822,8 +1857,11 @@
|
|
|
1822
1857
|
row.appendChild(body)
|
|
1823
1858
|
|
|
1824
1859
|
// elapsed and the short id, right-aligned and small: the two facts you scan
|
|
1825
|
-
// down the column rather than read
|
|
1826
|
-
|
|
1860
|
+
// down the column rather than read. They sit over the actions in one side
|
|
1861
|
+
// column (board.css .term-side); the narrow breakpoint dissolves it.
|
|
1862
|
+
const side = el('div', { class: 'term-side' })
|
|
1863
|
+
row.appendChild(side)
|
|
1864
|
+
side.appendChild(el('div', { class: 'term-clock' }, [
|
|
1827
1865
|
el('span', { class: 'term-when elapsed', 'data-elapsed-from': String(Date.parse(s.started_at) || 0), 'data-elapsed-format': 'compact', title: `started ${new Date(s.started_at).toLocaleString()}` }, [ago(s.elapsed_ms)]),
|
|
1828
1866
|
// the tail is `codex-99ab`, printed immediately after the word `codex`:
|
|
1829
1867
|
// the prefix is the agent name twice, and it is the half that squeezed
|
|
@@ -1859,7 +1897,7 @@
|
|
|
1859
1897
|
q.addEventListener('click', () => act(s.session_id, 'request-handoff', q))
|
|
1860
1898
|
actions.appendChild(q)
|
|
1861
1899
|
}
|
|
1862
|
-
|
|
1900
|
+
side.appendChild(actions)
|
|
1863
1901
|
term.appendChild(row)
|
|
1864
1902
|
return term
|
|
1865
1903
|
}
|
|
@@ -1924,7 +1962,7 @@
|
|
|
1924
1962
|
actions.appendChild(land)
|
|
1925
1963
|
}
|
|
1926
1964
|
if (s.active) {
|
|
1927
|
-
const h = el('button', { type: 'button', class:
|
|
1965
|
+
const h = el('button', { type: 'button', class: 'btn btn-secondary', title: 'save the bundle, stop this agent, start the next option in the same terminal', 'data-focus-key': `handoff:${s.session_id}` }, ['Hand off now'])
|
|
1928
1966
|
// the same confirm row End and the picker already use. This stops the
|
|
1929
1967
|
// current turn of a working agent, and the `h` key presses this button:
|
|
1930
1968
|
// an action that costs a turn asks first, whichever hand pressed it.
|
|
@@ -1961,7 +1999,7 @@
|
|
|
1961
1999
|
no.addEventListener('click', () => act(s.session_id, `requests/${encodeURIComponent(r.by)}/dismiss`, no))
|
|
1962
2000
|
actions.append(ok, no)
|
|
1963
2001
|
}
|
|
1964
|
-
|
|
2002
|
+
side.appendChild(actions)
|
|
1965
2003
|
term.appendChild(row)
|
|
1966
2004
|
|
|
1967
2005
|
// Why can't I land expander or fallback blocker message
|
|
@@ -2573,7 +2611,7 @@
|
|
|
2573
2611
|
// favicon are the only surface a browser gives a tab nobody is looking at.
|
|
2574
2612
|
// favicon.svg itself is never touched; the dot is a variant drawn inline.
|
|
2575
2613
|
const FAVICON = '/favicon.svg'
|
|
2576
|
-
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
2614
|
+
const FAVICON_DOT = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" rx="6" fill="#F3F4F7"/><path d="M8 5.5v8.5l4 4h5" stroke="#0E1012" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="19" cy="5" r="5" fill="#E64343"/></svg>')}`
|
|
2577
2615
|
function titleBadge(n) {
|
|
2578
2616
|
const title = n > 0 ? `(${n}) Leg` : 'Leg'
|
|
2579
2617
|
if (document.title !== title) document.title = title
|
|
@@ -2792,7 +2830,7 @@
|
|
|
2792
2830
|
const repos = [...new Set(finished.map((s) => s.repo_name).filter(Boolean))]
|
|
2793
2831
|
if (repos.length) meta.textContent += `, in ${repos.slice(0, 3).join(', ')}${repos.length > 3 ? ` and ${repos.length - 3} more` : ''}`
|
|
2794
2832
|
const btn = el('button', { type: 'button', class: 'btn btn-secondary', 'aria-expanded': finishedOpen ? 'true' : 'false', 'aria-controls': 'finished-drawer', 'data-focus-key': 'finished-toggle' },
|
|
2795
|
-
[finishedOpen ?
|
|
2833
|
+
[finishedOpen ? 'Hide' : 'View'])
|
|
2796
2834
|
btn.addEventListener('click', () => { finishedOpen = !finishedOpen; renderSessions(view) })
|
|
2797
2835
|
slot.appendChild(btn)
|
|
2798
2836
|
panel.hidden = !finishedOpen
|
|
@@ -2842,6 +2880,9 @@
|
|
|
2842
2880
|
orderDivergedAt = same ? 0 : (orderDivergedAt || Date.now())
|
|
2843
2881
|
const empty = document.querySelector('.region-terminals .empty-line')
|
|
2844
2882
|
if (empty) empty.hidden = list.some((s) => s.active || needsYou(s, notesOf.get(s.session_id) || []))
|
|
2883
|
+
// the first-run panel is the same fact in a lit panel (index.html #first-run)
|
|
2884
|
+
const firstRun = document.getElementById('first-run')
|
|
2885
|
+
if (firstRun) firstRun.hidden = list.length > 0
|
|
2845
2886
|
|
|
2846
2887
|
// A terminal that has ended or been lost is history, and history does not
|
|
2847
2888
|
// belong in the panel that shows what is live. It moves to the ledger.
|
|
@@ -2999,7 +3040,7 @@
|
|
|
2999
3040
|
// depends on it. board-updates.test.mjs uses the same pattern in board.js.
|
|
3000
3041
|
if (typeof module !== 'undefined') {
|
|
3001
3042
|
module.exports = {
|
|
3002
|
-
verdictLines, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
3043
|
+
verdictLines, verdictTarget, VERDICT_CH, SUB_CH, WARN_PCT, bindingOf, capFigure, capToken, shareClause, headline,
|
|
3003
3044
|
rankedNotes, needsYou, registerTokens, capacityPhrase, capacityNote, waitingNote, notifyWait, resetWait,
|
|
3004
3045
|
KEY_BUTTONS, KEY_MOVES,
|
|
3005
3046
|
// D14: the ring is state, not a pure function, so test/board-keyboard
|
package/src/board/strip.js
CHANGED
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
const drawer = document.getElementById('capacity-drawer')
|
|
141
141
|
if (btn) {
|
|
142
142
|
btn.setAttribute('aria-expanded', capacityOpen ? 'true' : 'false')
|
|
143
|
-
btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models
|
|
143
|
+
btn.textContent = capacityOpen ? 'Hide capacity and models' : 'Capacity and models'
|
|
144
144
|
}
|
|
145
145
|
if (drawer) drawer.hidden = !capacityOpen
|
|
146
146
|
}
|