The Phases view: a swimlane each

Card 56 took a phase's members off the Board; this is where they went. A fourth view beside Board, Sessions and Focus, holding one lane per phase card — every phase there is, not only the running ones, because a phase whose cards are all merged and which is waiting on its own PR still owns them.

A lane is a head, five stage columns and the phase's log. The head carries the phase, its progress, the member in flight and its own controls — hold, the phase branch, the phase card — and nothing that ends a phase, which stays a board move on the phase card. Four columns are the board's stages; the fifth is 'Merged in', not done/, because a member merged into the phase branch is finished as far as the phase is concerned and is not in main yet. Which members the branch holds is the runner's last pass while a phase runs, and the phase log once there are no more passes, so a phase in review/ still draws the work where it landed. The cards are the cards: cardFor() builds them here exactly as it builds them for the Board, agent line, chips, position-in-the-run chip and hover actions intact. The phase log sits under the lane, the runner's decisions in the order it made them, read off the card's own '## Phase log' — the only thing that can tell "not reached yet" from "started and ended badly".

A halted lane says so at the top of itself, names the card it stopped on, and offers run again beneath the reason. That action left the Board's phase card for the same reason: clearing a halt should mean having read what caused it, so the card offers a way into the view instead, and hold, which needs nothing read. The crossing the other way is the switcher, which carries how many phases are running and an alarm mark while one has halted — on every view, alongside the toast and the ticker line that already fire.

Covered by tests/test_phases_view.py: collect() reads a throwaway tasks/ tree and the page's own lane rules run over that reading in node (ordering, placement, the last column, the log, the all-merged phase in review/), plus source-level wiring invariants for the DOM half. test_phase_watch, test_card_actions and test_scroll_kept updated where this moves what they pin. Full suite green: 939 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-02 08:55:17 +02:00
co-authored by Claude Opus 5
parent a4fdb4f34d
commit a3388ed2ea
6 changed files with 967 additions and 28 deletions
+61 -3
View File
@@ -293,7 +293,7 @@ an agent is working; a blinking caret means output is still arriving. Night
theme by default; the header button switches to Daylight. Tokens live at the
top of `manager/board.html`.
The board has three views (header switcher):
The board has four views (header switcher):
- **Board** — the kanban, live, and only what you are holding yourself: a
phase's members are drawn elsewhere, with the phase card standing for
@@ -306,6 +306,10 @@ The board has three views (header switcher):
appears — when sync stops converging, saying whether it is behind
(origin unreachable, self-healing) or stalled on something only a human
can settle.
- **Phases** — a swimlane each: where the members the Board stopped drawing
actually live (see "The Phases view: a swimlane each"). The switcher
itself carries how many phases are running and a mark when one has
halted, so a halt is learned from whichever view you are on.
- **Sessions** — a flight recorder per session: a chronological timeline of
reads, edits, test runs, commits and card moves, with filters and expandable
output. Sessions persist to `local/state/sessions/<id>.jsonl`, so past ones
@@ -980,8 +984,62 @@ Focus are about runs, not stages, and a phase member's agent is an agent
like any other — it is counted by the header's live chip and by the tab
title wherever it is working.
Filtering the board to a phase's cards, and a Focus view for a phase, are
separate cards.
Where they are drawn instead is the next section. Filtering the board to a
phase's cards, and a Focus view for a phase, remain separate cards.
### The Phases view: a swimlane each
Where the members went. A fourth view beside Board, Sessions and Focus,
holding **one lane per phase card** — every phase there is, not only the
running ones, because a phase whose cards are all merged and which is
waiting on its own PR still owns them, and a lane that vanished exactly
when it needed merging would be a view you could not trust. Lanes are
ordered so a halt is never below the fold: halted, then running, then the
phases nobody has started, then the ones that are over. With no phase
anywhere the view says so in a sentence rather than drawing an empty grid.
A lane is a head, five stage columns and the phase's log:
- **The head** — the phase, its progress (`2 of 3 merged`), the member in
flight on the same activity line a card would use, and its own controls:
**‖ hold**, the phase branch, and the phase card, which opens in the
drawer. Those are all of them. Nothing here ends a phase — merging is a
board move on the phase card, and there is exactly one place where work
leaves the board.
- **Five columns, and the last one is the phase's own.** Four are the
board's stages; the fifth is `Merged in`, not `done/`, because a member
merged into the phase branch is finished as far as the phase is
concerned and is not in `main` yet. A member is drawn in the stage it is
actually in until the phase branch holds it, and then in that last
column whatever its card says. Which members the branch holds is the
runner's last pass while a phase is running, and the phase log once
there are no more passes — the same fact, written down by the same
runner, which is what keeps a phase in `review/` from drawing a lane
where nothing ever landed.
- **The cards are the cards.** `cardFor()` builds them here exactly as it
builds them for the Board: the live agent line, the CI and PR chips, the
`⟶ <phase> 2/3` chip that says where each sits in the run, and the same
hover actions. Nothing is shrunk to a token — that fidelity is the whole
advantage a room of its own buys.
- **The phase log, under the lane** — the runner's decisions in the order
it made them, read off the `## Phase log` section of the phase card
itself. It is the only thing that can tell "the phase has not reached
this card" from "it started it and it ended badly", and until this view
it had nowhere to be shown.
**A halted lane says so at the top of itself**: the reason, the card it
stopped on, and **▸ run again** beneath them. That action lives here and
not on the Board, because clearing a halt should mean having read what
caused it — the Board's halted phase card offers **⟶ phases** in its place,
and **‖ hold**, which needs nothing read.
The crossing matters more than the lane: a person on the Board has to
learn that a phase halted without being on this view, and a person here
has to be able to get back to the card that owns it. So the switcher
carries the running count and, in `--alarm`, a mark when something has
halted — on every view, holding until the phase is run again or held,
alongside the toast and the ticker line that already fire. And the lane
head's title and its `card ↗` chip open the phase card.
An optional **Assignee** line records who holds the card:
+413 -19
View File
@@ -91,6 +91,12 @@
.views button{padding:5px 12px;border:1px solid transparent;border-radius:6px;font-size:12.5px;font-weight:500;color:var(--muted)}
.views button:hover{color:var(--text);border-color:transparent}
.views button.on{background:var(--surface);border-color:var(--border);color:var(--text)}
/* the crossing: a phase halting is learned from whatever view you are on,
so the switcher carries how many are running and whether one stopped */
.views button{display:flex;align-items:center;gap:6px}
.views .vcount{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
.views button.on .vcount{color:var(--accent)}
.views .vhalt{width:6px;height:6px;border-radius:99px;background:var(--alarm);flex:none}
.livechip{
display:flex;align-items:center;gap:9px;padding:5px 12px 5px 10px;cursor:pointer;
background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px;
@@ -241,14 +247,16 @@
.card.has-acts:has(.hoveracts .armed) .toprow .high,
.card.has-acts:has(.hoveracts .busy) .toprow .pill.status,
.card.has-acts:has(.hoveracts .busy) .toprow .high{display:none}
.hoveracts button{
/* the lane head's own controls wear this too: a phase's actions are card
actions, and one look is what says so */
.lacts button,.hoveracts button{
position:relative;display:flex;align-items:center;gap:4px;
padding:4px 10px;min-height:24px;
background:var(--raised);border:1px solid var(--border);border-radius:99px;
font-size:11px;color:var(--muted);
}
.hoveracts button:hover{border-color:var(--accent);color:var(--accent)}
.hoveracts button .g{font-family:var(--mono);font-size:10px}
.lacts button:hover,.hoveracts button:hover{border-color:var(--accent);color:var(--accent)}
.lacts button .g,.hoveracts button .g{font-family:var(--mono);font-size:10px}
/* every state's label occupies the same grid cell, so the button is
born as wide as its widest state and never reshapes under the cursor */
.actlbl{display:inline-grid;justify-items:center}
@@ -258,14 +266,14 @@
button.armed .actlbl .l-arm{visibility:visible}
button.busy .actlbl .l-busy{visibility:visible}
/* armed = alarm, and the disarm window drains visibly along the bottom */
.hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)}
.lacts button.armed,.hoveracts button.armed,button.chip2.armed{color:var(--alarm);border-color:var(--alarm)}
button.armed::after{
content:"";position:absolute;left:9px;right:9px;bottom:2px;height:2px;
border-radius:99px;background:var(--alarm);transform-origin:left;
animation:drain 5s linear var(--arm-delay,0s) forwards;
}
/* busy = the request is away: breathe until the redraw or the timeout */
.hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default}
.lacts button.busy,.hoveracts button.busy,button.chip2.busy{color:var(--accent);border-color:var(--accent);cursor:default}
button.busy .g,button.busy .g2{animation:breathe 2.4s ease-in-out infinite}
/* ── the activity bar: log drawer + latest line + archive tray ── */
@@ -455,6 +463,63 @@
.also .card{flex:1 1 240px;cursor:default}
.also .card:hover{transform:none;border-color:var(--border-soft)}
/* ── phases: a swimlane each ── */
/* The room the five columns could never spare. One lane per phase, the
same five stages left to right, and the cards are the cards — full
fidelity is the whole advantage a view buys over a rail or a band. */
#view-phases{flex-direction:column;overflow-y:auto}
#lanes{display:flex;flex-direction:column;gap:14px;padding:14px 18px;flex:1}
.lane{
display:flex;flex-direction:column;gap:11px;padding:14px;
background:var(--surface);border:1px solid var(--border);border-radius:12px;
box-shadow:var(--shadow);
}
/* colour only means state, here as everywhere: a run alive, a run stopped */
.lane.running{border-color:color-mix(in oklab, var(--accent) 45%, var(--border))}
.lane.halted{border-color:color-mix(in oklab, var(--alarm) 55%, var(--border))}
.lhead{display:flex;align-items:center;gap:9px;min-width:0;flex-wrap:wrap}
.lhead .mark{width:7px;height:7px;border-radius:2px;flex:none;background:var(--idle)}
.lhead .mark.breathing{animation:breathe 2.4s ease-in-out infinite}
.lhead .lref{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.lhead .ltitle{
padding:0;border:none;background:none;border-radius:0;
font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--text);
}
.lhead .ltitle:hover{color:var(--accent);border:none}
.lhead .lprog{font-family:var(--mono);font-size:11.5px;color:var(--muted)}
/* the phase's own controls, in the head that owns them; they wear the
card actions' look because they are card actions in every other way */
.lacts{display:flex;align-items:center;gap:6px;margin-left:auto}
.lhalt{
display:flex;align-items:center;gap:10px;padding:9px 11px;background:var(--sunken);
border-left:2px solid var(--alarm);border-radius:0 7px 7px 0;font-size:12.5px;
}
.lhalt .lglyph{color:var(--alarm);font-family:var(--mono);font-size:12px;flex:none}
.lhalt .lwhy{flex:1;min-width:0;color:var(--muted);overflow-wrap:anywhere}
.lhalt .lwhy b{color:var(--alarm);font-weight:500}
.lstages{display:flex;gap:var(--gap);align-items:flex-start;overflow-x:auto}
.lcol{flex:1 1 0;min-width:var(--col-min);display:flex;flex-direction:column;gap:8px}
.lcol > h3{
margin:0;display:flex;align-items:center;gap:8px;padding:0 4px 2px;
font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);
}
.lcol > h3 .tickmark{width:6px;height:6px;border-radius:2px;flex:none}
.lcol > h3 .count{font-family:var(--mono);font-size:11px;font-weight:400;color:var(--dim)}
.lcol > h3 .note{margin-left:auto;font-weight:400;letter-spacing:0;text-transform:none;font-size:11px;color:var(--dim)}
.lcards{display:flex;flex-direction:column;gap:var(--gap)}
.lcol .empty{padding:14px 10px;font-size:11.5px}
/* the runner's decisions, in the order it wrote them — the only thing
that can tell "not reached yet" from "started and ended badly" */
.plog{display:flex;flex-direction:column;gap:4px;padding-top:10px;border-top:1px solid var(--line)}
.plog .phead{display:flex;align-items:baseline;gap:8px;font-size:12px;font-weight:500;color:var(--muted)}
.plog .phead .mono{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
.plog .plines{display:flex;flex-direction:column;gap:2px;max-height:160px;overflow-y:auto}
.plog .pline{display:flex;gap:9px;font-family:var(--mono);font-size:11.5px;line-height:1.5}
.plog .pline .stamp{color:var(--dim);flex:none}
.plog .pline .what{color:var(--muted);min-width:0;overflow-wrap:anywhere}
.plog .pline.bad .what{color:var(--alarm)}
.plog .pempty{font-size:12px;color:var(--dim)}
/* ── drawer ── */
#drawer{
position:fixed;top:0;right:0;bottom:0;width:min(460px,92vw);z-index:20;display:none;
@@ -581,6 +646,9 @@
</div>
<nav class="views" id="views">
<button data-view="board" class="on">Board</button>
<!-- the label is written by renderViews(): the running count, and the
mark that says a phase halted while you were somewhere else -->
<button data-view="phases">Phases</button>
<button data-view="flight">Sessions</button>
<button data-view="focus">Focus</button>
</nav>
@@ -598,6 +666,11 @@
<div id="board"></div>
</div>
<!-- phases: one swimlane each -->
<div class="view" id="view-phases">
<div id="lanes"></div>
</div>
<!-- sessions -->
<div class="view" id="view-flight">
<aside class="f-rail" id="frail"></aside>
@@ -1057,6 +1130,28 @@ function renderPhases() {
}));
}
/* The crossing this feature stands on, seen from the other side: a phase
halts while you are on the Board, and the switcher says so without you
having to go and look. The count is the phases running; the mark is a
halt, in the alarm every other stopped thing wears. A board with no
phase in flight has a switcher that reads exactly as it always did. */
function renderViews() {
const btn = document.querySelector('#views button[data-view="phases"]');
if (!btn) return;
const all = Object.values(S.state?.phases || {});
const running = all.filter(p => p.running).length;
const halted = all.filter(p => p.halted);
btn.innerHTML = 'Phases' +
(running ? `<span class="vcount">${running}</span>` : '') +
(halted.length ? '<span class="vhalt"></span>' : '');
btn.title = halted.length
? halted.map(p => `${p.file} halted${p.haltedAt ? ` at ${p.haltedAt}` : ''} — ` +
`${p.haltedWhy || p.halted}`).join(' · ') + ' — open Phases to read it and run it again'
: running
? `${running} phase${running > 1 ? 's' : ''} running — a swimlane each`
: 'One swimlane per phase: its cards, its log, its controls';
}
function setView(view) {
// leaving a view drops where you were in it: a hidden view's scrollers
// read 0 anyway, and coming back with a remembered offset would be
@@ -1079,7 +1174,7 @@ function setView(view) {
narrowed to a few characters still shows it. It is the in-flight glyph
the CI and copilot chips already use, not an emoji, and a quiet board
is the plain title, byte for byte. */
const VIEW_TITLES = { board: 'bench', flight: 'sessions', focus: 'focus' };
const VIEW_TITLES = { board: 'bench', phases: 'phases', flight: 'sessions', focus: 'focus' };
const WORKING_MARK = '◌';
function tabTitle(project, view, working) {
@@ -1105,14 +1200,23 @@ function render() {
renderTitle();
renderChip();
renderPhases();
renderViews();
renderSync();
if (S.view === 'board') renderBoard();
else if (S.view === 'flight') renderFlight();
else renderFocus();
if (S.view === 'flight') renderFlight();
else if (S.view === 'focus') renderFocus();
else renderCards();
renderBar();
renderLog();
}
/* Two views draw cards, and they draw the same cards: the Board's columns
and a phase lane's. Anything that changes what a card looks like —
selecting one, closing the drawer — redraws whichever of them you are
on, rather than assuming the Board. */
function renderCards() {
if (S.view === 'phases') renderLanes(); else renderBoard();
}
/* ── board ────────────────────────────────────────────────────────────── */
/* A phase member is not drawn on the Board view. It keeps its stage, its
@@ -1388,17 +1492,22 @@ function cardFor(task) {
+ 'this board becomes the one that advances it',
run: () => runPhase(task, { takeover: true }) }
: { glyph: '▸', label: 'run phase', confirm: 'run it?', busy: 'starting…',
title: ph && ph.halted
? 'Run it again — the halt is cleared and the phase carries on from '
+ 'where it stopped'
title: ph && ph.stopped
? 'Run it again — the phase carries on from where it was held'
: 'A branch of its own, each card in the list run on it in turn, and '
+ 'one PR into main at the end',
run: () => runPhase(task) };
// a halt holds until the phase is run again or held: both are here,
// because a person who has read it and does not want to carry on
// needs a way to say so that is not walking the card backwards
// A halt holds until the phase is run again or held. Holding is here,
// because a person who does not want to carry on needs a way to say
// so that is not walking the card backwards — but running it again is
// not: clearing a halt should mean having read what caused it, and
// the reason is in the lane, under the halt. So this points there.
const room = { glyph: '⟶', label: 'phases', busy: 'opening…',
title: 'Open the Phases view — this phase\'s lane, the halt that stopped it '
+ 'and ▸ run again beneath it',
run: () => { setView('phases'); return true; } };
if (ph && ph.running) actions.push(hold);
else if (ph && ph.halted) actions.push(start, hold);
else if (ph && ph.halted) actions.push(room, hold);
else actions.push(start);
} else if (task.stage === 'in-progress') {
actions.push(held
@@ -1965,7 +2074,7 @@ async function addToPhase(task, phase) {
function showDetail(task) {
const changed = !S.selected || S.selected.file !== task.file;
S.selected = task;
renderBoard();
renderCards();
if (changed) $('#drawerbody').scrollTop = 0;
}
@@ -2077,7 +2186,7 @@ function renderDrawer() {
const member = findTask(row.dataset.member);
if (member) showDetail(member);
}));
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderBoard(); });
$('#closeDrawer').addEventListener('click', () => { S.selected = null; renderCards(); });
}
function allRecentEvents() {
@@ -2179,6 +2288,291 @@ async function unarchiveLast() {
await loadState();
}
/* ── phases: a swimlane each ──────────────────────────────────────────────
The Board draws the work you are personally holding; this draws what the
phases are holding. One lane per phase card, the same five stages left to
right, and the cards are the cards — the very nodes cardFor() builds for
the Board, agent line, chips and hover actions intact. That fidelity is
the whole reason this is a view and not a rail squeezed into five
columns that could never spare the width.
Nothing here ends a phase. Merging is a board move on the phase card, so
there is exactly one place where work leaves the board, and it is not
this one. */
/* Where a lane sits in the stack: a halt first, because it is the one
thing on this page waiting on a person and it must not be below the
fold; then a run in flight; then the phases nobody has started; then the
ones that are over. Every phase card draws a lane — a phase whose
members are all merged and which is waiting on its own PR still owns
them, and a lane that vanished exactly when it needed merging would be
a view you could not trust. */
function laneRank(task) {
const snap = (S.state.phases || {})[task.file];
if (snap && snap.halted) return 0;
if (snap && snap.running) return 1;
return task.stage === 'done' ? 3 : 2;
}
function lanePhases() {
return allTasks().filter(t => t.isPhase).sort((a, b) =>
laneRank(a) - laneRank(b) ||
(+a.number || 0) - (+b.number || 0) ||
a.file.localeCompare(b.file));
}
/* `07`, `7` and `#007` are one card, on this side of the wire too —
taskfiles.canonical_number, in the language the page is written in. */
function canonicalNumber(number) {
const n = parseInt(number, 10);
return Number.isNaN(n) ? String(number ?? '') : String(n);
}
/* The phase log: the `- <date> <time> · <entry>` lines the runner appends
to a `## Phase log` section on the phase card, read here exactly as
phases.py writes them. It is the record of every decision a run made,
and the only thing that can tell "the phase has not reached this card"
from "it started it and it ended badly" — until this view it had
nowhere to be shown. */
const PHASE_LOG_LINE = /^-\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+·\s+(.+?)\s*$/;
function phaseLog(task) {
const lines = (task.body || '').split('\n');
const start = lines.findIndex(line => /^##\s+Phase log\s*$/.test(line));
if (start < 0) return [];
const entries = [];
for (const line of lines.slice(start + 1)) {
if (/^##\s/.test(line)) break;
const match = PHASE_LOG_LINE.exec(line.trim());
if (match) entries.push({ at: match[1], text: match[2] });
}
return entries;
}
/* Which members the phase branch already holds. While a run is on, the
runner's last pass has just read it off git; once the phase leaves
in-progress/ there are no more passes — and the log, written by the same
runner as it merged them, still remembers. Two durable sources for one
fact, which is what keeps a phase in review/ from drawing a lane where
nothing ever landed. */
function mergedIn(task) {
const merged = new Set();
const snap = (S.state.phases || {})[task.file];
for (const m of ((snap && snap.members) || [])) {
if (m.state === 'merged' && m.number) merged.add(canonicalNumber(m.number));
}
for (const entry of phaseLog(task)) {
const match = /^(\d+) merged into /.exec(entry.text);
if (match) merged.add(canonicalNumber(match[1]));
}
return merged;
}
/* The columns a lane runs. The first four are the board's own stages, named
by the server that names them everywhere else. The last one is the
phase's own and it is deliberately not done/: a member merged into the
phase branch is finished as far as the phase is concerned, and is not in
main yet — main waits on the phase's own PR, at the end, for a person.
So the column is named for what it actually holds. */
function laneColumns() {
const stages = ((S.state.board || {}).stages || []).filter(s => s.slug !== 'done');
return [...stages.map(s => ({ slug: s.slug, label: s.label, tint: STAGE_TINT[s.slug] })),
{ slug: 'merged', label: 'Merged in', tint: 'var(--calm)',
note: 'on the phase branch' }];
}
/* Where one member is drawn: the stage it is actually in, unless the phase
branch already holds it — then it is in the lane's last column whatever
its card says, because within the phase that work has landed. A card in
done/ is finished by any reading. */
function laneColumnOf(task, merged) {
if (task.number && merged.has(canonicalNumber(task.number))) return 'merged';
return task.stage === 'done' ? 'merged' : task.stage;
}
/* Every card the phase lists, in run order, each with the column it is
drawn in. The card itself is the whole of what is known about it — this
only says where in the lane it goes. A listed number that resolves to no
card is not here: the phase card wears `phase drift` for that, as it
already did. */
function laneMembers(task) {
const merged = mergedIn(task);
const members = [];
(task.members || []).forEach((listed, index) => {
const card = findTask(listed.file);
if (!card) return;
members.push({ pos: index + 1, task: card, column: laneColumnOf(card, merged) });
});
return members;
}
/* What the head says it has got through, counted off the same placement
the columns draw by — so the head and the last column can never
disagree about how far the phase has got. */
function laneProgress(members) {
return { done: members.filter(m => m.column === 'merged').length,
total: members.length };
}
function laneLog(task) {
const entries = phaseLog(task);
const lines = entries.map(entry =>
`<div class="pline${/^halted\b/.test(entry.text) ? ' bad' : ''}">` +
`<span class="stamp">${esc(entry.at)}</span>` +
`<span class="what">${esc(entry.text)}</span></div>`).join('');
return `<div class="plog"><div class="phead">Phase log` +
`<span class="mono">${entries.length ? entries.length + ' in the order they were made'
: 'nothing yet'}</span></div>` +
(entries.length
? `<div class="plines" data-phase="${esc(task.file)}">${lines}</div>`
: `<div class="pempty">The runner writes a line here for every decision it makes — ` +
`a run started, a card started, a card merged, a halt.</div>`) +
`</div>`;
}
function laneFor(task) {
const el = document.createElement('section');
const snap = (S.state.phases || {})[task.file];
const members = laneMembers(task);
const at = laneProgress(members);
const halted = !!(snap && snap.halted);
const running = !!(snap && snap.running);
el.className = 'lane' + (halted ? ' halted' : running ? ' running' : '');
el.dataset.phase = task.file;
const branch = (snap && snap.branch) || 'phase/' + task.file.replace(/\.md$/, '');
// the branch is only claimed to be missing when the runner has just
// looked; without a pass to read, the name is offered without a verdict
const uncut = !!(snap && !snap.started);
const flight = phaseFlight(task);
const head =
`<div class="lhead">` +
`<span class="mark${running ? ' breathing' : ''}" style="background:${
halted ? 'var(--alarm)' : running ? 'var(--accent)' : STAGE_TINT[task.stage]}"></span>` +
(task.number ? `<span class="lref">#${esc(task.number)}</span>` : '') +
`<button class="ltitle" data-open="1" title="Open the phase card its text, ` +
`its list and its PR">${esc(task.title)}</button>` +
`<span class="lprog">${at.total ? `${at.done} of ${at.total} merged`
: 'no cards listed'} · ${esc(task.stage)}</span>` +
`<button class="chip2" data-open="1" title="Open the phase card">card<span class="g2"></span></button>` +
`<span class="chip2${uncut ? ' dim' : ''}" title="${uncut
? 'The phase branch has not been cut yet — running the phase cuts it from the newest main'
: "The phase's integration branch: every member is branched from its tip and merged back into it"
}">${esc(branch)}</span>` +
`<span class="lacts"></span></div>`;
// the card the phase stopped on, named rather than left as a number
const stoppedOn = halted && snap.haltedAt
? members.find(m => m.task.number &&
canonicalNumber(m.task.number) === canonicalNumber(snap.haltedAt))
: null;
const halt = halted
? `<div class="lhalt"><span class="lglyph"></span><span class="lwhy">` +
`<b>halted${snap.haltedAt ? ` at #${esc(snap.haltedAt)}` : ''}</b>` +
`${stoppedOn ? ' · ' + esc(stoppedOn.task.title) : ''} — ` +
`${esc(snap.haltedWhy || snap.halted)}</span>` +
`<span class="lacts"></span></div>`
: '';
// a run in flight, in the same well the cards use for the same fact
const well = !halted && flight
? `<div class="well"><span class="lead">·</span><span class="wbody">${esc(flight.line)}` +
`<span class="caret"></span></span></div>`
: '';
el.innerHTML = head + halt + well +
`<div class="lstages" data-phase="${esc(task.file)}"></div>` + laneLog(task);
const stages = el.querySelector('.lstages');
for (const col of laneColumns()) {
const here = members.filter(m => m.column === col.slug);
const box = document.createElement('div');
box.className = 'lcol';
box.innerHTML = `<h3><span class="tickmark" style="background:${col.tint}"></span>` +
`<span>${esc(col.label)}</span><span class="count">${here.length}</span>` +
`<span class="note">${esc(col.note || '')}</span></h3>`;
const cards = document.createElement('div');
cards.className = 'lcards';
if (!here.length) cards.innerHTML = '<div class="empty"></div>';
// the cards are the cards: same builder, same actions, same chips —
// including the ⟶ chip that says where each one sits in the run
for (const m of here) cards.appendChild(cardFor(m.task));
box.appendChild(cards);
stages.appendChild(box);
}
el.querySelectorAll('[data-open]').forEach(btn =>
btn.addEventListener('click', () => showDetail(task)));
// ▸ run again lives here and nowhere else: clearing a halt should mean
// having read what caused it, and the reason is the line above it
if (halted) {
laneAction(el.querySelector('.lhalt .lacts'), task,
{ glyph: '▸', label: 'run again', confirm: 'run it again?', busy: 'starting…',
title: 'Clear the halt and carry on from where it stopped — every card already '
+ 'merged into the phase branch stays merged',
run: () => runPhase(task) });
}
// ‖ hold means here what it means everywhere: the run stops, the member
// agent in flight stops with it, and nothing is unwound
if (running || halted) {
laneAction(el.querySelector('.lhead .lacts'), task,
{ glyph: '‖', label: 'hold', confirm: 'hold it?', busy: 'holding…',
title: 'Stop the phase and the agent it has in flight — the phase branch, every '
+ 'card already merged into it and every worktree stay exactly as they are',
run: () => holdPhase(task) });
}
return el;
}
/* A phase's controls are card actions in every way that matters, so they
go through the one arm-then-fire machine, keyed like every other. */
function laneAction(slot, task, act) {
if (!slot) return;
const btn = document.createElement('button');
btn.innerHTML = `<span class="g">${act.glyph}</span>${actLabel(act.label, act.confirm, act.busy)}`;
btn.title = act.title;
wireAction(btn, `${task.file}::${act.label}`, act);
slot.appendChild(btn);
}
function renderLanes() {
const view = $('#view-phases'), wrap = $('#lanes');
// where you were looking, taken before the wipe throws the nodes away:
// the page scrolls down, each lane's stages scroll sideways, each log
// scrolls in its own box
markScroll('v:phases', view);
wrap.querySelectorAll('.lstages').forEach(el =>
markScroll('v:lanestages:' + el.dataset.phase, el));
wrap.querySelectorAll('.plines').forEach(el =>
markScroll('v:phaselog:' + el.dataset.phase, el));
wrap.innerHTML = '';
const phases = lanePhases();
if (!phases.length) {
// an empty grid of five columns would be a room pretending to be full
wrap.innerHTML = '<div class="empty">No phases yet. A phase is an ordinary card ' +
'marked <b>Type: Phase</b> with a <code>## Cards</code> section listing the cards ' +
'it runs, in the order they run.</div>';
} else {
for (const task of phases) wrap.appendChild(laneFor(task));
}
restoreScroll('v:phases', view);
wrap.querySelectorAll('.lstages').forEach(el =>
restoreScroll('v:lanestages:' + el.dataset.phase, el));
wrap.querySelectorAll('.plines').forEach(el => {
// a log you have not touched opens on its newest line, which is the one
// that says where the phase is; once you have scrolled it, that is where
// it stays through every redraw
const key = 'v:phaselog:' + el.dataset.phase;
if (scrollMarks[key]) restoreScroll(key, el);
else el.scrollTop = el.scrollHeight;
});
if (S.selected) {
S.selected = allTasks().find(t => t.file === S.selected.file) || null;
}
renderDrawer();
}
/* ── sessions ─────────────────────────────────────────────────────────── */
function pickFlightSid() {
@@ -2671,7 +3065,7 @@ document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if ($('#sheetwrap').classList.contains('open')) { closeSheet(); }
else if (S.fileView) { S.fileView = null; renderDrawer(); }
else if (S.selected) { S.selected = null; renderBoard(); }
else if (S.selected) { S.selected = null; renderCards(); }
});
/* the drawer: drag its left edge to widen */
+4 -4
View File
@@ -167,10 +167,10 @@ class OneSlotBuilderTests(unittest.TestCase):
cls.html = BOARD.read_text(encoding="utf-8")
def test_hover_actions_and_command_chips_share_the_machine(self):
self.assertEqual(len(re.findall(r"(?<!function )wireAction\(btn,", self.html)), 3,
"exactly three call sites: the slot builder, the "
"$-command chips and the ⌸ archive chip — all on "
"the one machine")
self.assertEqual(len(re.findall(r"(?<!function )wireAction\(btn,", self.html)), 4,
"exactly four call sites: the slot builder, the "
"$-command chips, the ⌸ archive chip and a phase "
"lane's head — all on the one machine")
self.assertNotIn("btn.firstChild.textContent", self.html,
"the old per-chip label swap is the fork this kills")
self.assertNotIn("btn.lastElementChild.textContent", self.html)
+9 -2
View File
@@ -504,8 +504,15 @@ class TheCardsActions(unittest.TestCase):
self.assertIn("label: 'hold'", self.block)
self.assertIn("confirm: 'hold it?'", self.block)
def test_a_halt_offers_both_ways_out(self):
self.assertIn("ph.halted) actions.push(start, hold)", self.block)
def test_a_halt_offers_the_way_out_and_the_way_to_the_reason(self):
"""A halt still holds until the phase is run again or held, and
holding is here. Running it again moved to the Phases view with
card 57: it sits under the reason the phase stopped, because
clearing a halt should mean having read what caused it. So this
card carries phases instead the way to that reading."""
self.assertIn("ph.halted) actions.push(room, hold)", self.block)
self.assertIn("setView('phases')", self.block)
self.assertNotIn("ph.halted) actions.push(start", self.block)
def test_the_slot_never_holds_three(self):
self.assertIn("else if (actions.length < 2) {", self.html,
+478
View File
@@ -0,0 +1,478 @@
"""The Phases view: a swimlane each (task 57).
Card 56 took a phase's members off the Board; this is where they went. One
lane per phase, the same five stages left to right, and the last column is
the phase's own — a member merged into the phase branch is finished as far
as the phase is concerned and is nowhere near `main`.
Two halves, as elsewhere for board.html. The rules are exercised for real:
`taskfiles.collect()` reads a throwaway tasks/ directory exactly as the
board does, and the page's own functions are lifted out of board.html and
run in node over that reading. The rest that the lane draws whole cards,
that run again lives here and hold means hold, that nothing in this view
ends a phase is a source-level invariant, board.html being a single file
with inline JS and no frontend test runner.
python3 -m unittest discover -s tests -v
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "manager" / "core"))
import config # noqa: E402
import taskfiles # noqa: E402
BOARD = REPO / "manager" / "core" / "board.html"
NODE = shutil.which("node")
# The rules a lane is drawn by, lifted from the page as written.
PARTS = (
r"const STAGE_TINT = \{.*?\};",
r"function allTasks\(\) \{[^\n]*\}",
r"function findTask\(file\) \{[^\n]*\}",
r"function laneRank\(task\) \{.*?\n\}",
r"function lanePhases\(\) \{.*?\n\}",
r"function canonicalNumber\(number\) \{.*?\n\}",
r"const PHASE_LOG_LINE = [^\n]*",
r"function phaseLog\(task\) \{.*?\n\}",
r"function mergedIn\(task\) \{.*?\n\}",
r"function laneColumns\(\) \{.*?\n\}",
r"function laneColumnOf\(task, merged\) \{.*?\n\}",
r"function laneMembers\(task\) \{.*?\n\}",
r"function laneProgress\(members\) \{.*?\n\}",
)
def harness() -> str:
html = BOARD.read_text(encoding="utf-8")
out = []
for pattern in PARTS:
match = re.search(pattern, html, re.S)
if match is None:
raise AssertionError(f"board.html no longer defines {pattern!r}")
out.append(match.group(0))
return "\n".join(out)
def card(title: str, *, kind: str | None = None, cards: str | None = None,
status: str = "Backlog", log: str | None = None) -> str:
"""A task file as a person — or the runner — would write it."""
text = f"# {title}\n\n**Status:** {status}\n**Priority:** Medium\n"
if kind:
text += f"**Type:** {kind}\n"
text += "\nWhat this card is for.\n"
if cards is not None:
text += f"\n## Cards\n{cards}\n"
if log is not None:
text += f"\n## Phase log\n\n{log}\n"
return text
@unittest.skipUnless(NODE, "node is needed to run the page's own rules")
class LaneCase(unittest.TestCase):
"""One tasks/ directory per test, read as the board reads it, then laid
out by the page's own functions."""
@classmethod
def setUpClass(cls):
cls.src = harness()
def setUp(self):
tmp = Path(tempfile.mkdtemp(prefix="bench-lanes-")).resolve()
self.addCleanup(shutil.rmtree, tmp, True)
self.tasks = tmp / "tasks"
for slug in (*config.STAGE_DIRS, "archive"):
(self.tasks / slug).mkdir(parents=True)
self.addCleanup(setattr, config, "TASKS", config.TASKS)
config.TASKS = self.tasks
self.addCleanup(setattr, config, "TM_ROOT", config.TM_ROOT)
config.TM_ROOT = tmp
def write(self, filename: str, text: str, stage: str = "backlog") -> None:
(self.tasks / stage / filename).write_text(text, encoding="utf-8")
def state(self, **extra) -> dict:
return {"board": taskfiles.collect(), "agents": [], "phases": {}, **extra}
def run_js(self, expression: str, **extra) -> object:
script = (self.src + "\nvar S = { state: " + json.dumps(self.state(**extra))
+ " };\nconsole.log(JSON.stringify(" + expression + "));\n")
out = subprocess.run([NODE, "-e", script], capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
return json.loads(out.stdout)
def lanes(self, **extra) -> list[str]:
"""The phases drawn, in the order they are drawn."""
return self.run_js("lanePhases().map(t => t.file)", **extra)
def lane(self, file: str, **extra) -> dict:
"""One lane's reading: its columns, and where each member landed."""
return self.run_js(
"(() => { const t = findTask(" + json.dumps(file) + ");"
" const members = laneMembers(t);"
" return { columns: laneColumns().map(c => ({ slug: c.slug, label: c.label,"
" files: members.filter(m => m.column === c.slug).map(m => m.task.file) })),"
" members: members.map(m => ({ pos: m.pos, file: m.task.file,"
" stage: m.task.stage, column: m.column })),"
" progress: laneProgress(members) }; })()", **extra)
# the shape most of these use: a phase and the three cards it lists,
# spread over the stages they are genuinely in
def a_phase_of_three(self, phase_stage: str = "in-progress",
log: str | None = None) -> None:
self.write("31-stand-up-site.md", card("31 — Stand up site/",
status="Review"), "review")
self.write("32-serve-it.md", card("32 — Serve it", status="In Progress"),
"in-progress")
self.write("33-landing.md", card("33 — The landing page", status="To Do"),
"to-do")
self.write("40-the-site.md",
card("40 — Ship the site", kind="Phase", log=log,
cards="- 31 — Stand up site/\n- 32 — Serve it\n"
"- 33 — The landing page\n",
status=config.STAGE_LABELS[phase_stage]), phase_stage)
def snapshot(self, **extra) -> dict:
"""What the runner's last pass would have published for the phase."""
members = [{"number": "31", "file": "31-stand-up-site.md",
"title": "31 — Stand up site/", "stage": "review",
"state": "merged", "why": ""},
{"number": "32", "file": "32-serve-it.md",
"title": "32 — Serve it", "stage": "in-progress",
"state": "running", "why": ""},
{"number": "33", "file": "33-landing.md",
"title": "33 — The landing page", "stage": "to-do",
"state": "pending", "why": ""}]
snap = {"file": "40-the-site.md", "branch": "phase/40-the-site",
"members": members, "running": True, "started": True,
"halted": "", "haltedAt": None, "haltedWhy": None}
snap.update(extra)
return {"40-the-site.md": snap}
class OneLanePerPhase(LaneCase):
def test_every_phase_card_draws_a_lane(self):
self.a_phase_of_three()
self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
cards="- 31 — Stand up site/\n",
status="To Do"), "to-do")
self.assertEqual(self.lanes(), ["40-the-site.md", "41-the-docs.md"])
def test_a_board_with_no_phases_draws_no_lanes(self):
"""…and the view says so plainly rather than rendering an empty grid
the sentence itself is asserted in the wiring half below."""
self.write("31-stand-up-site.md", card("31 — Stand up site/"))
self.assertEqual(self.lanes(), [])
def test_a_halted_phase_is_never_below_the_fold(self):
"""It is the one thing on this page waiting on a person."""
self.a_phase_of_three()
self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
cards="- 33 — The landing page\n",
status="In Progress"), "in-progress")
phases = self.snapshot()
phases["41-the-docs.md"] = {"file": "41-the-docs.md", "members": [],
"running": False, "started": True,
"halted": "halted at 33 — it is not ready",
"haltedAt": "33", "haltedWhy": "it is not ready"}
self.assertEqual(self.lanes(phases=phases),
["41-the-docs.md", "40-the-site.md"],
"the halt sorts above the run in flight")
def test_a_run_in_flight_sorts_above_a_phase_nobody_has_started(self):
self.a_phase_of_three()
self.write("39-earlier.md", card("39 — An earlier phase", kind="Phase",
cards="- 33 — The landing page\n",
status="To Do"), "to-do")
self.assertEqual(self.lanes(phases=self.snapshot()),
["40-the-site.md", "39-earlier.md"])
def test_a_finished_phase_sorts_last(self):
self.a_phase_of_three(phase_stage="done")
self.write("41-the-docs.md", card("41 — Ship the docs", kind="Phase",
cards="- 33 — The landing page\n",
status="To Do"), "to-do")
self.assertEqual(self.lanes(), ["41-the-docs.md", "40-the-site.md"])
class ALaneHoldsThePhasesCards(LaneCase):
def test_every_listed_card_is_drawn_in_the_stage_it_is_in(self):
self.a_phase_of_three()
lane = self.lane("40-the-site.md")
self.assertEqual([m["file"] for m in lane["members"]],
["31-stand-up-site.md", "32-serve-it.md", "33-landing.md"])
columns = {c["slug"]: c["files"] for c in lane["columns"]}
self.assertEqual(columns["review"], ["31-stand-up-site.md"])
self.assertEqual(columns["in-progress"], ["32-serve-it.md"])
self.assertEqual(columns["to-do"], ["33-landing.md"])
def test_a_member_carries_its_position_in_the_run(self):
self.a_phase_of_three()
self.assertEqual([(m["pos"], m["file"]) for m in
self.lane("40-the-site.md")["members"]],
[(1, "31-stand-up-site.md"), (2, "32-serve-it.md"),
(3, "33-landing.md")])
def test_a_listed_number_that_names_no_card_draws_nothing_and_breaks_nothing(self):
self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
cards="- 99 — a card that is not here\n",
status="In Progress"), "in-progress")
lane = self.lane("40-the-site.md")
self.assertEqual(lane["members"], [])
self.assertEqual(lane["progress"], {"done": 0, "total": 0})
class TheLastColumnIsThePhasesOwn(LaneCase):
"""A member merged into the phase branch is finished as far as the phase
is concerned, and is not in main yet so the column is not done/."""
def columns(self, **extra) -> dict[str, list[str]]:
return {c["slug"]: c["files"]
for c in self.lane("40-the-site.md", **extra)["columns"]}
def test_the_five_columns_end_in_the_phases_own(self):
self.a_phase_of_three()
columns = self.lane("40-the-site.md")["columns"]
self.assertEqual([c["slug"] for c in columns],
["backlog", "to-do", "in-progress", "review", "merged"])
self.assertEqual(columns[-1]["label"], "Merged in")
self.assertNotIn("done", [c["slug"] for c in columns])
def test_the_runners_reading_puts_a_merged_member_there(self):
"""Its card is in review/; the phase branch already holds it."""
self.a_phase_of_three()
columns = self.columns(phases=self.snapshot())
self.assertEqual(columns["merged"], ["31-stand-up-site.md"])
self.assertEqual(columns["review"], [])
def test_the_log_remembers_it_once_the_runner_has_stopped_passing(self):
"""The edge case: every member merged, the phase waiting on its own
PR in review/, so no snapshot exists any more. The lane still draws
the work where it landed, off the log the runner wrote."""
self.a_phase_of_three(
phase_stage="review",
log="- 2026-08-01 10:00 · run started\n"
"- 2026-08-01 10:20 · 31 merged into phase/40-the-site\n"
"- 2026-08-01 11:05 · 32 merged into phase/40-the-site\n"
"- 2026-08-01 12:00 · 33 merged into phase/40-the-site\n"
"- 2026-08-01 12:01 · every card merged — opening the phase PR\n")
lane = self.lane("40-the-site.md")
self.assertEqual([c["files"] for c in lane["columns"]][-1],
["31-stand-up-site.md", "32-serve-it.md", "33-landing.md"])
self.assertEqual(lane["progress"], {"done": 3, "total": 3})
def test_a_card_in_done_is_finished_by_any_reading(self):
self.write("31-stand-up-site.md", card("31 — Stand up site/",
status="Done"), "done")
self.write("40-the-site.md", card("40 — Ship the site", kind="Phase",
cards="- 31 — Stand up site/\n",
status="In Progress"), "in-progress")
self.assertEqual(self.lane("40-the-site.md")["columns"][-1]["files"],
["31-stand-up-site.md"])
def test_progress_is_what_the_last_column_holds(self):
"""The head and the column it counts can never disagree, because
they are one placement read twice."""
self.a_phase_of_three()
self.assertEqual(self.lane("40-the-site.md",
phases=self.snapshot())["progress"],
{"done": 1, "total": 3})
class ThePhaseLog(LaneCase):
"""The runner's decisions, read off the card it wrote them on."""
def log(self, **extra) -> list[dict]:
return self.run_js("phaseLog(findTask('40-the-site.md'))", **extra)
def test_the_entries_are_in_the_order_the_runner_wrote_them(self):
self.a_phase_of_three(
log="- 2026-08-01 10:00 · run started\n"
"- 2026-08-01 10:02 · 31 started\n"
"- 2026-08-01 10:20 · 31 merged into phase/40-the-site\n")
self.assertEqual([e["text"] for e in self.log()],
["run started", "31 started",
"31 merged into phase/40-the-site"])
self.assertEqual(self.log()[0]["at"], "2026-08-01 10:00")
def test_a_halt_is_an_entry_like_any_other(self):
self.a_phase_of_three(
log="- 2026-08-01 10:00 · run started\n"
"- 2026-08-01 10:40 · halted at 32 — its CI is red\n")
self.assertEqual(self.log()[-1]["text"], "halted at 32 — its CI is red")
def test_a_phase_nobody_has_run_has_no_log(self):
self.a_phase_of_three()
self.assertEqual(self.log(), [])
def test_the_log_stops_at_the_next_heading(self):
self.write("40-the-site.md",
card("40 — Ship the site", kind="Phase", status="In Progress",
cards="- 31 — Stand up site/\n",
log="- 2026-08-01 10:00 · run started\n\n"
"## PR review\n\n- 2026-08-01 10:30 · not a log line\n"),
"in-progress")
self.assertEqual([e["text"] for e in self.log()], ["run started"])
def test_what_the_log_says_is_merged_is_read_as_merged(self):
self.a_phase_of_three(
log="- 2026-08-01 10:20 · 31 merged into phase/40-the-site\n")
self.assertEqual(self.run_js(
"[...mergedIn(findTask('40-the-site.md'))]"), ["31"])
def test_a_started_card_is_not_a_merged_one(self):
"""The distinction the log exists to make: this card has run, and
that is not the same as its work having landed."""
self.a_phase_of_three(
log="- 2026-08-01 10:00 · run started\n"
"- 2026-08-01 10:02 · 32 started\n")
self.assertEqual(self.run_js(
"[...mergedIn(findTask('40-the-site.md'))]"), [])
class Wiring(unittest.TestCase):
"""board.html's own half: the view, the crossing from the Board, and
what this room deliberately cannot do."""
@classmethod
def setUpClass(cls):
cls.html = BOARD.read_text(encoding="utf-8")
def body(self, pattern: str) -> str:
match = re.search(pattern, self.html, re.S)
self.assertIsNotNone(match, f"board.html no longer defines {pattern!r}")
return match.group(0)
def test_the_switcher_offers_a_fourth_view(self):
self.assertIn('<button data-view="phases">Phases</button>', self.html)
self.assertIn('id="view-phases"', self.html)
self.assertIn("phases: 'phases'", self.body(r"const VIEW_TITLES = [^\n]*"),
"the tab title names the view like the other three")
def test_the_view_renders_where_the_others_do(self):
render = self.body(r"function render\(\) \{.*?\n\}")
self.assertIn("renderViews();", render)
self.assertIn("renderCards();", render)
self.assertIn("if (S.view === 'phases') renderLanes();",
self.body(r"function renderCards\(\) \{.*?\n\}"))
def test_the_switcher_carries_the_count_and_the_halt(self):
"""The crossing: a phase halts while you are on the Board and you
learn it there the toast and the ticker are the server's, and this
is the one that keeps saying it after they have scrolled away."""
views = self.body(r"function renderViews\(\) \{.*?\n\}")
self.assertIn("p.running", views)
self.assertIn("p.halted", views)
self.assertIn("vcount", views)
self.assertIn("vhalt", views)
self.assertIn(".views .vhalt{", self.html)
self.assertIn("var(--alarm)", self.body(r"\.views \.vhalt\{[^}]*\}"),
"a halt is alarm-coloured wherever it is worn")
def test_the_cards_are_the_cards(self):
"""Full fidelity is the advantage a whole view buys: the lane builds
the same nodes the Board does, not a shrunken token."""
lane = self.body(r"function laneFor\(task\) \{.*?\n\}")
self.assertIn("cardFor(m.task)", lane)
self.assertEqual(len(re.findall(r"appendChild\(cardFor\(", self.html)), 2,
"cardFor() builds the cards for the two views that draw "
"them — the Board's columns and a lane's")
def test_the_lane_head_holds_the_phase_and_the_log_sits_under_it(self):
lane = self.body(r"function laneFor\(task\) \{.*?\n\}")
self.assertIn("holdPhase(task)", lane)
self.assertIn("data-open", lane)
self.assertIn("branch", lane)
self.assertLess(lane.index('<div class="lstages"'), lane.index("laneLog(task)"),
"the log is read under the lane, after its columns")
def test_run_again_lives_here_and_not_on_the_board(self):
"""Clearing a halt should mean having read what caused it, so the
action sits beneath the reason and the Board's phase card points
at the room rather than offering the launch itself."""
lane = self.body(r"function laneFor\(task\) \{.*?\n\}")
self.assertIn("'run again'", lane)
self.assertIn("runPhase(task)", lane)
self.assertIn(".lhalt .lacts", lane,
"▸ run again is inside the halt, under its reason")
card_fn = self.body(r"function cardFor\(task\) \{.*?\n\}")
self.assertIn("else if (ph && ph.halted) actions.push(room, hold);", card_fn)
self.assertIn("setView('phases')", card_fn)
def test_holding_stops_the_run_and_unwinds_nothing(self):
hold = self.body(r"async function holdPhase\(task\) \{.*?\n\}")
self.assertIn("/api/phase/stop", hold)
lane = self.body(r"function laneFor\(task\) \{.*?\n\}")
self.assertIn("stay exactly as they are", lane)
def test_nothing_here_ends_the_phase(self):
"""Merging is a board move on the phase card: one place where work
leaves the board, and this is not it."""
for pattern in (r"function laneFor\(task\) \{.*?\n\}",
r"function renderLanes\(\) \{.*?\n\}"):
body = self.body(pattern)
for forbidden in ("completeSheet", "/api/task/complete", "rawMove(",
"addToPhase", "'/api/move'"):
self.assertNotIn(forbidden, body,
f"{forbidden} does not belong in the Phases view")
def test_the_empty_view_says_so_plainly(self):
render = self.body(r"function renderLanes\(\) \{.*?\n\}")
self.assertIn("No phases yet.", render)
self.assertIn("lanePhases()", render)
def test_the_lane_keeps_your_place_across_a_redraw(self):
render = self.body(r"function renderLanes\(\) \{.*?\n\}")
self.assertIn("markScroll('v:phases'", render)
self.assertIn("restoreScroll('v:phases'", render)
self.assertIn("v:phaselog:", render)
@unittest.skipUnless(NODE, "node is needed to parse the page")
class ThePageStillParses(unittest.TestCase):
def test_the_inline_script_parses(self):
html = BOARD.read_text(encoding="utf-8")
scripts = re.findall(r"<script[^>]*>(.*?)</script>", html, re.S)
self.assertTrue(scripts, "board.html has no inline script")
with tempfile.TemporaryDirectory() as tmp:
for index, script in enumerate(scripts):
source = Path(tmp) / f"page-{index}.js"
source.write_text(script, encoding="utf-8")
out = subprocess.run([NODE, "--check", str(source)],
capture_output=True, text=True)
self.assertEqual(out.returncode, 0, out.stderr)
+2
View File
@@ -375,6 +375,8 @@ class RenderersKeepPlaceTests(unittest.TestCase):
".tl": "markScroll('v:tl:'", # #ftl, a session timeline
".f-rail": "markScroll('v:rail'", # #frail, the sessions list
"#view-focus": "markScroll('v:focus'",
"#view-phases": "markScroll('v:phases'", # the lanes, down
".lstages": "markScroll('v:lanestages:'", # one lane, sideways
".c-strip": "markScroll('v:cstrip'", # #cstrip, the stage strip
"#logbody": "markScroll('log'",
"#drawerbody": "markScroll(S.drawerKey",