diff --git a/AGENTS.md b/AGENTS.md index db30428..26b9523 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: → archived ()` commit naming both paths, and ⌘Z commits its own way back. That diff --git a/manager/core/board.html b/manager/core/board.html index b4b3fce..d7e81a3 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -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 ? '
' + chips.map(c => { const g = c.glyph ? `${c.glyph}` : ''; const p = c.pre ? `${c.pre}` : ''; + if (c.arch) return ``; if (c.href) return `${p}${esc(c.label)}${g}`; if (c.act) return ``; if (c.phase) return ``; @@ -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) { `${esc(busy || rest)}`; } +/* 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 + `::`, 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() { diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 0e32c7a..3a324ac 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -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(), diff --git a/tests/test_archive_chip.py b/tests/test_archive_chip.py new file mode 100644 index 0000000..c2d9933 --- /dev/null +++ b/tests/test_archive_chip.py @@ -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("::") + 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() diff --git a/tests/test_card_actions.py b/tests/test_card_actions.py index fa47a5a..de69c34 100644 --- a/tests/test_card_actions.py +++ b/tests/test_card_actions.py @@ -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"(?