An archive chip on the card, where the card is
Every card in backlog/, to-do/ and done/ gets a ⌸ chip at the end of its footer row — the tray's own glyph — arming on the first click and archiving on the second, through the routes and the ARCHIVE_FROM guard that already existed. In-progress and review cards do not get one: the board offers what it will do. Committed by hand after the run that wrote it ended mid-turn without committing. The work is the agent's; the suite passes on it (875 tests) and it is unchanged from what the run left staged.
This commit is contained in:
@@ -108,6 +108,16 @@ out of every column, never deleted, Status set to `Archived`. The toast says
|
||||
without an undo in the same breath. Cards in the working stages
|
||||
(in-progress, review) cannot be archived; finish or walk them back first.
|
||||
|
||||
The same action sits on the card, for the common tidy-up the length of the
|
||||
board is too far to drag: a **⌸** chip at the right-hand end of the footer
|
||||
row, wearing the tray's own glyph, arming on the first click and firing on
|
||||
the second. It is there on exactly the cards the tray accepts — a working
|
||||
card has no chip at all rather than one that refuses — and it is one
|
||||
action, not two: the same request, the same ⌘Z toast, the same count on
|
||||
the tray. Which stages may be archived from is answered once, by the
|
||||
server that enforces it, and sent with the state; the drag gesture and the
|
||||
chip both read that answer.
|
||||
|
||||
Archiving is a move, so it commits like one: under `BOARD_COMMIT_MOVES`
|
||||
the rename into `tasks/archive/` lands in a single `board: <n> → archived
|
||||
(<name>)` commit naming both paths, and ⌘Z commits its own way back. That
|
||||
|
||||
+45
-1
@@ -202,6 +202,12 @@
|
||||
.chip2.bad{color:var(--alarm);border-color:color-mix(in oklab, var(--alarm) 45%, transparent)}
|
||||
.chip2.dim{color:var(--dim);border-style:dashed;cursor:help}
|
||||
.chip2.dim:hover{color:var(--dim);border-color:var(--border)}
|
||||
/* the archive chip is the one action in a row of destinations, and the
|
||||
exception is why it sits at the far end: pushed there by margin, and
|
||||
quiet — no border until you are on it — because archiving is a
|
||||
tidy-up, not somewhere the card is asking you to go. Hover, armed and
|
||||
busy all outrank this rule, so it never dulls a live state. */
|
||||
.chip2.arch{margin-left:auto;color:var(--dim);border-color:transparent}
|
||||
.card .whorow{display:flex;align-items:center;gap:7px;min-width:0}
|
||||
.card .initial{
|
||||
display:flex;align-items:center;justify-content:center;width:18px;height:18px;flex:none;
|
||||
@@ -1092,6 +1098,7 @@ function renderTitle() {
|
||||
|
||||
function render() {
|
||||
if (!S.state) return;
|
||||
forgetActsOfVanishedCards();
|
||||
renderTitle();
|
||||
renderChip();
|
||||
renderPhases();
|
||||
@@ -1170,6 +1177,14 @@ function joinablePhases(task) {
|
||||
return stage ? stage.tasks.filter(t => t.isPhase) : [];
|
||||
}
|
||||
|
||||
/* Whether this card may be archived — the server's own ARCHIVE_FROM, sent
|
||||
with the state. The rule (backlog, to-do and done; never a working
|
||||
stage) is written down once, on the side that enforces it, so the chip
|
||||
and the drag-to-the-bar gesture can never offer different cards. */
|
||||
function canArchive(task) {
|
||||
return (S.state.archiveFrom || []).includes(task.stage);
|
||||
}
|
||||
|
||||
function cardFor(task) {
|
||||
const el = document.createElement('article');
|
||||
const agent = agentOnTask(task.file);
|
||||
@@ -1448,10 +1463,18 @@ function cardFor(task) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// archiving, on the card instead of the length of the board away: the
|
||||
// tray's own glyph, and only on the stages the tray itself accepts —
|
||||
// a working card is finished or walked back, never tidied away
|
||||
if (canArchive(task)) {
|
||||
chips.push({ label: 'archive', pre: '⌸', cls: 'arch', arch: true,
|
||||
title: 'Archive this card — out of every column, never deleted. ⌘Z brings it back.' });
|
||||
}
|
||||
const chipRow = chips.length
|
||||
? '<div class="chiprow">' + chips.map(c => {
|
||||
const g = c.glyph ? `<span class="g2">${c.glyph}</span>` : '';
|
||||
const p = c.pre ? `<span class="g2">${c.pre}</span>` : '';
|
||||
if (c.arch) return `<button class="chip2 ${c.cls}" data-arch="1" title="${esc(c.title)}">${p}${actLabel(c.label, 'archive it?', 'archiving…')}</button>`;
|
||||
if (c.href) return `<a class="chip2 ${c.cls}" href="${esc(c.href)}" target="_blank" rel="noopener" title="${esc(c.title)}">${p}${esc(c.label)}${g}</a>`;
|
||||
if (c.act) return `<button class="chip2 ${c.cls}" data-drive="${c.act}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
|
||||
if (c.phase) return `<button class="chip2 ${c.cls}" data-phase="${esc(c.phase)}" title="${esc(c.title)}">${p}${esc(c.label)}${g}</button>`;
|
||||
@@ -1480,6 +1503,13 @@ function cardFor(task) {
|
||||
e.stopPropagation();
|
||||
if (btn.dataset.drive === 'go') startDrive(task); else parkDrive();
|
||||
}));
|
||||
el.querySelectorAll('[data-arch]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => e.stopPropagation());
|
||||
wireAction(btn, `${task.file}::archive`, {
|
||||
label: 'archive', confirm: 'archive it?', busy: 'archiving…',
|
||||
run: () => archiveCard(task.file, task.stage),
|
||||
});
|
||||
});
|
||||
el.querySelectorAll('[data-cmd]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => e.stopPropagation());
|
||||
const name = btn.dataset.cmd;
|
||||
@@ -1509,7 +1539,7 @@ function cardFor(task) {
|
||||
el.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ file: task.file, from: task.stage }));
|
||||
if (['backlog', 'to-do', 'done'].includes(task.stage)) {
|
||||
if (canArchive(task)) {
|
||||
S.dragging = { file: task.file, from: task.stage };
|
||||
renderBar();
|
||||
}
|
||||
@@ -1537,6 +1567,19 @@ function actLabel(rest, confirm, busy) {
|
||||
`<span class="l-busy">${esc(busy || rest)}</span></span>`;
|
||||
}
|
||||
|
||||
/* An armed window belongs to the card it was opened on. A card can leave
|
||||
the board while one is open — archived from another board, or moved by
|
||||
hand on disk — and the window must go with it, so a card that comes
|
||||
straight back (⌘Z, or a teammate's undo) is never found already armed
|
||||
by a click nobody made against the card as it now stands. Keys are
|
||||
`<file>::<action>`, and a task file never contains `::`. */
|
||||
function forgetActsOfVanishedCards() {
|
||||
const live = new Set(allTasks().map(t => t.file));
|
||||
for (const key of Object.keys(S.acts)) {
|
||||
if (!live.has(key.split('::')[0])) delete S.acts[key];
|
||||
}
|
||||
}
|
||||
|
||||
function wireAction(btn, key, act) {
|
||||
const st = S.acts[key];
|
||||
if (st && st.until > Date.now()) {
|
||||
@@ -2015,6 +2058,7 @@ async function archiveCard(file, from) {
|
||||
const data = await res.json();
|
||||
toast(res.ok ? `Archived ${file} — ⌘Z brings it back` : (data.error || 'archive failed'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
async function unarchiveLast() {
|
||||
|
||||
@@ -53,6 +53,11 @@ def state_payload() -> dict:
|
||||
# else's". Empty outside team mode: nothing claims anything there.
|
||||
"me": taskfiles.actor_name() if config.COMMIT_MOVES else "",
|
||||
"archivedCount": taskfiles.archived_count(),
|
||||
# which stages an archive may come from — the same set `archive_task`
|
||||
# refuses anything outside, sent so the card's ⌸ chip and the drag
|
||||
# gesture offer exactly what the server will do, from one authority
|
||||
"archiveFrom": sorted(taskfiles.ARCHIVE_FROM,
|
||||
key=lambda slug: taskfiles.STAGE_ORDER.get(slug, 99)),
|
||||
"sync": sync.status(),
|
||||
"boardEvents": board_events,
|
||||
"now": time.time(),
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""An archive button on the card, where the card is (task 47).
|
||||
|
||||
Archiving used to be one gesture only: drag the card the length of the
|
||||
board onto the activity bar. The card now carries the same action itself —
|
||||
a `⌸` chip at the right-hand end of its footer row, arming on the first
|
||||
click and firing on the second.
|
||||
|
||||
board.html is a single file with inline JS and no frontend test runner, so
|
||||
most of these are source-level invariants: the ones that, if broken, would
|
||||
give back the thing the task was written against — a second copy of the
|
||||
archivable-stages rule in the page, a bespoke confirmation, an archive that
|
||||
loses the ⌘Z promise, or an armed chip left behind by a card that has gone.
|
||||
|
||||
python3 -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORE = REPO / "manager" / "core"
|
||||
sys.path.insert(0, str(CORE))
|
||||
|
||||
import config # noqa: E402
|
||||
import taskfiles # noqa: E402
|
||||
|
||||
BOARD = CORE / "board.html"
|
||||
TRAY_GLYPH = "⌸"
|
||||
|
||||
|
||||
def html() -> str:
|
||||
return BOARD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class OneAuthorityForTheRule(unittest.TestCase):
|
||||
"""Which cards may be archived is the server's answer. The page asks
|
||||
for it rather than keeping a copy that can drift out of step."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = html()
|
||||
|
||||
def test_the_working_stages_are_not_archivable(self):
|
||||
"""The rule itself, unchanged: finish or walk a card back, never
|
||||
tidy it away mid-flight."""
|
||||
self.assertEqual(taskfiles.ARCHIVE_FROM, {"backlog", "to-do", "done"})
|
||||
for working in ("in-progress", "review"):
|
||||
self.assertIn(working, config.STAGE_DIRS)
|
||||
self.assertNotIn(working, taskfiles.ARCHIVE_FROM)
|
||||
with self.assertRaises(ValueError):
|
||||
taskfiles.archive_task("47-nothing.md", working)
|
||||
|
||||
def test_the_state_payload_carries_the_set(self):
|
||||
import httpd
|
||||
payload = httpd.state_payload()
|
||||
self.assertIn("archiveFrom", payload)
|
||||
self.assertEqual(set(payload["archiveFrom"]), taskfiles.ARCHIVE_FROM)
|
||||
self.assertEqual(payload["archiveFrom"],
|
||||
[slug for slug, _ in config.STAGES
|
||||
if slug in taskfiles.ARCHIVE_FROM],
|
||||
"sent in board order, so the JSON reads like the board")
|
||||
|
||||
def test_the_page_holds_no_second_copy(self):
|
||||
for fossil in ("'backlog', 'to-do', 'done'", '"backlog", "to-do", "done"'):
|
||||
self.assertNotIn(fossil, self.html,
|
||||
"the archivable stages are the server's list, "
|
||||
"not a literal in the page")
|
||||
|
||||
def test_both_gestures_ask_the_same_helper(self):
|
||||
"""canArchive() — defined once, reading S.state.archiveFrom, and
|
||||
used by the chip and by the drag-to-the-bar gesture alike."""
|
||||
body = re.search(r"function canArchive\(task\) \{(.*?)\n\}", self.html, re.S)
|
||||
self.assertIsNotNone(body, "canArchive is gone")
|
||||
self.assertIn("S.state.archiveFrom", body.group(1))
|
||||
self.assertEqual(len(re.findall(r"canArchive\(task\)", self.html)), 3,
|
||||
"one definition and exactly two callers: the chip "
|
||||
"and the dragstart handler")
|
||||
|
||||
|
||||
class TheChipOnTheCard(unittest.TestCase):
|
||||
"""A `⌸` chip, last in the footer row, quiet until you are on it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = html()
|
||||
cls.push = re.search(r"if \(canArchive\(task\)\) \{(.*?)\n \}",
|
||||
cls.html, re.S)
|
||||
|
||||
def rule(self, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\{([^}]*)\}", self.html)
|
||||
self.assertIsNotNone(m, f"board.html lost its {selector} rule")
|
||||
return m.group(1).replace(" ", "").replace("\n", "")
|
||||
|
||||
def test_it_wears_the_trays_glyph(self):
|
||||
"""The whole point is that the two are visibly one action, so the
|
||||
glyph appears exactly where the tray's does and nowhere else."""
|
||||
self.assertIsNotNone(self.push, "the archive chip is not pushed")
|
||||
self.assertIn(f"pre: '{TRAY_GLYPH}'", self.push.group(1))
|
||||
tray = re.search(r"\$\('#tray'\)\.innerHTML =(.*?);", self.html, re.S)
|
||||
self.assertIn(TRAY_GLYPH, tray.group(1), "the tray lost its glyph")
|
||||
self.assertEqual(self.html.count(TRAY_GLYPH), 2,
|
||||
"one glyph, two places: the tray and the card's chip")
|
||||
|
||||
def test_it_is_the_last_chip_in_the_row(self):
|
||||
chips = self.html.index("const chipRow = chips.length")
|
||||
commands = self.html.index("for (const cmd of (S.state.commands || []))")
|
||||
self.assertLess(commands, self.push.start(),
|
||||
"the archive chip is pushed after every tool chip")
|
||||
self.assertLess(self.push.end(), chips,
|
||||
"…and before the row is rendered")
|
||||
|
||||
def test_it_sits_at_the_far_end_and_rests_quiet(self):
|
||||
self.assertIn("cls: 'arch'", self.push.group(1))
|
||||
arch = self.rule(".chip2.arch")
|
||||
self.assertIn("margin-left:auto", arch, "pushed to the right-hand end")
|
||||
self.assertIn("border-color:transparent", arch)
|
||||
self.assertIn("color:var(--dim)", arch)
|
||||
|
||||
def test_hover_armed_and_busy_all_outrank_the_quiet_rule(self):
|
||||
"""`.chip2.arch` is a two-class selector on purpose: every live
|
||||
state is a class plus an element or a pseudo-class, so none of
|
||||
them is dulled back to dim by the resting rule."""
|
||||
flat = self.html.replace(" ", "").replace("\n", "")
|
||||
for live in ("button.chip2:hover{", "button.chip2.armed{", "button.chip2.busy{"):
|
||||
self.assertIn(live, flat, f"{live} must stay more specific than .chip2.arch")
|
||||
|
||||
def test_no_chip_where_the_server_would_refuse(self):
|
||||
"""Nothing renders it on its own terms: the one condition is
|
||||
canArchive, so in-progress and review cards simply have no chip."""
|
||||
card = re.search(r"function cardFor\(task\) \{.*?\n\}\n", self.html, re.S).group(0)
|
||||
self.assertEqual(len(re.findall(r"data-arch", card)), 2,
|
||||
"the chip is written once and wired once")
|
||||
self.assertEqual(len(re.findall(r"canArchive\(task\)", card)), 2)
|
||||
|
||||
|
||||
class ArmThenFire(unittest.TestCase):
|
||||
"""Nothing about archiving invents its own confirmation."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = html()
|
||||
|
||||
def test_it_walks_the_one_state_machine(self):
|
||||
wiring = re.search(r"el\.querySelectorAll\('\[data-arch\]'\).*?\n \}\);",
|
||||
self.html, re.S)
|
||||
self.assertIsNotNone(wiring, "the archive chip is not wired")
|
||||
self.assertIn("wireAction(btn, `${task.file}::archive`", wiring.group(0))
|
||||
self.assertIn("confirm: 'archive it?'", wiring.group(0))
|
||||
self.assertIn("busy: 'archiving…'", wiring.group(0))
|
||||
self.assertIn("archiveCard(task.file, task.stage)", wiring.group(0))
|
||||
|
||||
def test_the_label_swaps_in_place_like_every_other_action(self):
|
||||
push_to_row = self.html[self.html.index("if (c.arch) return"):]
|
||||
line = push_to_row.split("\n", 1)[0]
|
||||
self.assertIn("actLabel(c.label, 'archive it?', 'archiving…')", line,
|
||||
"rest / confirm / busy share one grid cell")
|
||||
self.assertIn("<button", line, "an action chip is a button")
|
||||
push = re.search(r"if \(canArchive\(task\)\) \{(.*?)\n \}",
|
||||
self.html, re.S).group(1)
|
||||
self.assertIn("label: 'archive'", push, "and the resting label says what it does")
|
||||
|
||||
def test_no_bespoke_confirmation_anywhere_near_it(self):
|
||||
for fossil in ("window.confirm", "confirm('", "confirm(`"):
|
||||
self.assertNotIn(fossil, self.html,
|
||||
"arm-then-fire is the confirmation")
|
||||
|
||||
def test_a_refused_archive_reports_itself(self):
|
||||
"""fireAction can only unlock on failure if the runner tells it
|
||||
the truth — the same contract runCommand and stopAgent keep."""
|
||||
body = re.search(r"async function archiveCard\(file, from\) \{(.*?)\n\}",
|
||||
self.html, re.S)
|
||||
self.assertIsNotNone(body, "archiveCard is gone")
|
||||
self.assertIn("return res.ok", body.group(1))
|
||||
|
||||
def test_the_click_does_not_open_the_card(self):
|
||||
wiring = re.search(r"el\.querySelectorAll\('\[data-arch\]'\).*?\n \}\);",
|
||||
self.html, re.S).group(0)
|
||||
self.assertIn("e.stopPropagation()", wiring)
|
||||
|
||||
|
||||
class TheUndoStaysThePromise(unittest.TestCase):
|
||||
"""One path to the API, so the sentence that makes a one-click archive
|
||||
safe is told whichever gesture asked for it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = html()
|
||||
|
||||
def test_one_place_calls_the_api(self):
|
||||
self.assertEqual(self.html.count("'/api/archive'"), 1,
|
||||
"the chip and the drop share archiveCard")
|
||||
|
||||
def test_the_toast_names_the_undo(self):
|
||||
body = re.search(r"async function archiveCard\(file, from\) \{(.*?)\n\}",
|
||||
self.html, re.S).group(1)
|
||||
self.assertIn("⌘Z brings it back", body)
|
||||
self.assertIn("await loadState()", body,
|
||||
"the tray's count is read from the state, so it "
|
||||
"follows only if the archive reloads it")
|
||||
|
||||
def test_the_chips_tooltip_says_it_too(self):
|
||||
push = re.search(r"if \(canArchive\(task\)\) \{(.*?)\n \}",
|
||||
self.html, re.S).group(1)
|
||||
self.assertIn("⌘Z brings it back", push)
|
||||
self.assertIn("never deleted", push)
|
||||
|
||||
def test_the_bar_still_archives_what_is_dropped_on_it(self):
|
||||
self.assertIn("if (d) archiveCard(d.file, d.from);", self.html,
|
||||
"the drag gesture keeps working exactly as it did")
|
||||
self.assertIn("unarchiveLast();", self.html, "⌘Z is still bound")
|
||||
|
||||
|
||||
class NoStaleArmedChip(unittest.TestCase):
|
||||
"""A card archived by someone else, or moved by hand on disk, takes
|
||||
its armed windows with it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.html = html()
|
||||
|
||||
def test_render_forgets_the_windows_of_cards_that_left(self):
|
||||
body = re.search(r"function forgetActsOfVanishedCards\(\) \{(.*?)\n\}",
|
||||
self.html, re.S)
|
||||
self.assertIsNotNone(body, "the sweep is gone")
|
||||
self.assertIn("allTasks()", body.group(1))
|
||||
self.assertIn("key.split('::')[0]", body.group(1),
|
||||
"act keys are <file>::<action>")
|
||||
self.assertIn("delete S.acts[key]", body.group(1))
|
||||
|
||||
def test_the_sweep_runs_before_anything_is_drawn(self):
|
||||
head = re.search(r"function render\(\) \{\n(.*?)\n renderTitle", self.html, re.S)
|
||||
self.assertIn("forgetActsOfVanishedCards();", head.group(1))
|
||||
|
||||
def test_every_act_key_is_a_task_file(self):
|
||||
"""The sweep reads the key's first segment as a filename, so every
|
||||
key wireAction is given must start with one."""
|
||||
keys = re.findall(r"wireAction\(btn, `([^`]+)`", self.html)
|
||||
self.assertTrue(keys, "no wired actions found")
|
||||
for key in keys:
|
||||
self.assertTrue(key.startswith("${task.file}::"), f"stray act key {key!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -167,16 +167,18 @@ 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)), 2,
|
||||
"exactly two call sites: the slot builder and the "
|
||||
"$-command chips — both on the one machine")
|
||||
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.assertNotIn("btn.firstChild.textContent", self.html,
|
||||
"the old per-chip label swap is the fork this kills")
|
||||
self.assertNotIn("btn.lastElementChild.textContent", self.html)
|
||||
|
||||
def test_every_action_declares_its_participle(self):
|
||||
for word in ("starting…", "holding…", "checking…", "reviewing…",
|
||||
"acting…", "asking…", "moving…", "reopening…", "running…"):
|
||||
"acting…", "asking…", "moving…", "reopening…", "running…",
|
||||
"archiving…"):
|
||||
self.assertIn(word, self.html, f"busy label {word!r} missing")
|
||||
|
||||
def test_misses_die_at_the_slot(self):
|
||||
|
||||
Reference in New Issue
Block a user