diff --git a/manager/core/board.html b/manager/core/board.html
index c4376b1..a97927f 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -447,6 +447,17 @@
#drawer .dbody h2{font-size:14px;margin:18px 0 6px}
#drawer .dbody h3{font-size:13px;margin:14px 0 4px}
#drawer .dbody p,#drawer .dbody li{font-size:12.5px;color:var(--muted)}
+ #drawer .dbody ul,#drawer .dbody ol{margin:6px 0;padding-left:18px}
+ #drawer .dbody li{margin:3px 0}
+ #drawer .dbody li>ul,#drawer .dbody li>ol{margin:3px 0}
+ /* task-list items: a glyph, not an input — the file is the source of truth */
+ #drawer .dbody li.tick{list-style:none;margin-left:-18px}
+ #drawer .dbody li.tick .box{
+ display:inline-block;width:11px;height:11px;margin-right:7px;
+ border:1px solid var(--border);border-radius:3px;
+ font-size:9px;line-height:10px;text-align:center;
+ }
+ #drawer .dbody li.tick.on .box{border-color:var(--calm);color:var(--calm)}
#drawer .dbody strong{color:var(--text)}
#drawer .dbody code{font-family:var(--mono);font-size:11.5px;background:var(--sunken);padding:1px 4px;border-radius:3px}
#drawer .dbody pre{background:var(--sunken);border-radius:8px;padding:10px 12px;overflow-x:auto}
@@ -1900,7 +1911,8 @@ async function openExtra(dir, name) {
}
}
-// Small markdown renderer — enough for these task files, no dependencies.
+// Small markdown renderer — for these task files, which are hard-wrapped
+// prose: a logical line spans several source lines. No dependencies.
function md(src) {
const blocks = esc(src).split(/\n{2,}/);
const inline = (t) => t
@@ -1909,6 +1921,56 @@ function md(src) {
.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1$2')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
+ /* Lists: group physical lines into logical items before rendering. A new
+ item begins only at a marker; a line without one is continuation text
+ joined to the item above with a space — that is what stops a wrapped
+ item from sprouting a second bullet. A marker indented past the level
+ it sits in opens a nested list, and a shallower one closes back to the
+ level that fits, so children indent under their parent instead of
+ flattening beside it. */
+ const MARKER = /^([ \t]*)(?:[-*]|\d+\.)[ \t]+(.*)$/;
+ const ORDERED = /^[ \t]*\d+\./;
+ const TICK = /^\[([ xX])\]\s*(.*)$/; // - [ ] / - [x], read-only here
+
+ const listTree = (lines) => {
+ const root = { ordered: ORDERED.test(lines[0]), items: [] };
+ const stack = [{ indent: null, list: root }];
+ let last = null;
+ for (const line of lines) {
+ const m = line.match(MARKER);
+ if (!m) { // continuation of the item above
+ if (last && line.trim()) last.text += ' ' + line.trim();
+ continue;
+ }
+ const indent = m[1].replace(/\t/g, ' ').length;
+ const top = () => stack[stack.length - 1];
+ if (top().indent === null) top().indent = indent;
+ while (stack.length > 1 && indent < top().indent) stack.pop();
+ if (last && indent > top().indent) {
+ last.sub = { ordered: ORDERED.test(line), items: [] };
+ stack.push({ indent, list: last.sub });
+ }
+ last = { text: m[2], sub: null };
+ top().list.items.push(last);
+ }
+ return root;
+ };
+
+ const listHtml = (list) => {
+ const tag = list.ordered ? 'ol' : 'ul';
+ return `<${tag}>` + list.items.map((it) => {
+ const t = it.text.match(TICK);
+ const body = inline(t ? t[2] : it.text) + (it.sub ? listHtml(it.sub) : '');
+ if (!t) return `
${body}
`;
+ // A glyph, never an input: the task file is the source of truth and
+ // the drawer is not an editor. Done reads as settled, open is neutral.
+ const done = t[1] !== ' ';
+ return `
` +
+ `` +
+ `${done ? '✓' : ''}${body}
`;
+ }).join('') + `${tag}>`;
+ };
+
let inFence = false, fenced = [];
const out = [];
for (const block of blocks) {
@@ -1923,7 +1985,8 @@ function md(src) {
}
continue;
}
- const lines = block.split('\n');
+ const text = block.replace(/\s+$/, ''); // the split leaves the last block a trailing newline
+ const lines = text.split('\n');
if (lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) {
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim()));
const head = cells(lines[0]);
@@ -1932,21 +1995,20 @@ function md(src) {
head.map(h => `
${h}
`).join('') + '' +
rows.map(r => '
' + r.map(c => `
${c}
`).join('') + '
').join('') +
'');
- } else if (/^#{1,6}\s/.test(block)) {
+ } else if (/^#{1,6}\s/.test(text)) {
out.push(lines.map(l => {
const m = l.match(/^(#{1,6})\s+(.*)$/);
return m ? `${inline(m[2])}` : `
${inline(l)}
`;
}).join(''));
- } else if (/^\s*[-*]\s/.test(block)) {
- out.push('
`);
diff --git a/tests/test_drawer_markdown.py b/tests/test_drawer_markdown.py
new file mode 100644
index 0000000..db78df6
--- /dev/null
+++ b/tests/test_drawer_markdown.py
@@ -0,0 +1,387 @@
+"""The drawer renders a wrapped list item as one item (task 41).
+
+`md()` in board.html used to treat every *physical* line inside a block as
+a unit. Task files are hard-wrapped at ~74 columns, so the second line of
+an item became its own bullet, `- [ ]` rendered as a literal bracket pair,
+nested lists flattened, and paragraphs kept the author's ragged edge via
+` `.
+
+board.html is a single file with inline JS and no frontend test runner, so
+this suite lifts `esc()` and `md()` straight out of the page and runs them
+under node — the renderer is a pure function of its input, so its actual
+output is what to assert on. Node is not a dependency of bench itself, so
+those checks skip when it is absent; the source-level invariants at the
+bottom always run and are in the same style as the board's other
+`test_*.py` checks on board.html.
+
+ python3 -m unittest discover -s tests -v
+"""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+BOARD = ROOT / "manager" / "core" / "board.html"
+NODE = shutil.which("node")
+
+HTML = BOARD.read_text(encoding="utf-8")
+
+
+def lift(pattern: str, what: str) -> str:
+ m = re.search(pattern, HTML, re.M | re.S)
+ assert m, f"board.html lost {what}"
+ return m.group(0)
+
+
+HARNESS = (
+ lift(r"^const esc = \(s\) =>.*?\n.*?\n", "its esc() helper")
+ + lift(r"^function md\(src\) \{\n.*?\n\}\n", "its md() renderer")
+ + "process.stdout.write(md(require('fs').readFileSync(0, 'utf8')));\n"
+)
+
+
+class RendererCase(unittest.TestCase):
+ """Base: run the page's own md() over a markdown string."""
+
+ @classmethod
+ def setUpClass(cls):
+ if not NODE:
+ return
+ cls._dir = tempfile.TemporaryDirectory()
+ cls.js = Path(cls._dir.name) / "md.js"
+ cls.js.write_text(HARNESS, encoding="utf-8")
+
+ @classmethod
+ def tearDownClass(cls):
+ if NODE:
+ cls._dir.cleanup()
+
+ def render(self, src: str) -> str:
+ out = subprocess.run([NODE, str(self.js)], input=src, text=True,
+ capture_output=True)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ return out.stdout
+
+ INNERMOST = re.compile(r"<(ul|ol)>((?:(?!<(?:ul|ol)>).)*?)\1>", re.S)
+
+ def items(self, html: str) -> list[str]:
+ """The text of each top-level
: nested lists dropped, markup
+ stripped, so a test can assert on what the reader sees."""
+ body = re.sub(r"(ul|ol)>\s*$", "", re.sub(r"^\s*<(ul|ol)>", "", html))
+ while self.INNERMOST.search(body): # peel nested lists off
+ body = self.INNERMOST.sub("", body)
+ body = re.sub(r'', "", body) # the tick glyph
+ return [re.sub(r"<[^>]+>", "", li).strip()
+ for li in re.findall(r"
]*>(.*?)
", body, re.S)]
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
+class WrappedItemsTests(RendererCase):
+ """One bullet per item, however the author wrapped it."""
+
+ def test_a_wrapped_item_is_one_item(self):
+ """The live bug: 'serves the built landing page' / 'over' were two
+ bullets because the source line broke between them."""
+ html = self.render(
+ "- Given a request for the site, when it is served, then the\n"
+ " worker serves the built landing page\n"
+ "- A second item\n")
+ self.assertEqual(html.count("
", html)
+
+ def test_ordered_lists_group_the_same_way(self):
+ html = self.render(
+ "1. The board creates a git worktree on a new\n"
+ " branch from the newest main it can see\n"
+ "2. The agent works in the worktree\n")
+ self.assertTrue(html.startswith(""), html[:40])
+ self.assertEqual(len(self.items(html)), 2)
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
+class TaskListTests(RendererCase):
+ """`- [ ]` / `- [x]` become checkboxes, and only ever glyphs."""
+
+ SRC = ("- [ ] Given an Acceptance list whose items wrap, then there\n"
+ " is exactly one bullet per item\n"
+ "- [x] Fenced code blocks are unchanged\n")
+
+ def test_no_bracket_survives_as_text(self):
+ html = self.render(self.SRC)
+ text = " ".join(self.items(html))
+ self.assertNotIn("[", text)
+ self.assertNotIn("]", text)
+
+ def test_the_item_text_survives_beside_the_box(self):
+ self.assertEqual(
+ self.items(self.render(self.SRC)),
+ ["Given an Acceptance list whose items wrap, then there "
+ "is exactly one bullet per item",
+ "Fenced code blocks are unchanged"])
+
+ def test_ticked_and_unticked_are_distinguishable(self):
+ html = self.render(self.SRC)
+ lis = re.findall(r"
]*)>", html)
+ self.assertEqual(len(lis), 2)
+ self.assertIn('class="tick"', lis[0]) # open: neutral
+ self.assertIn('class="tick on"', lis[1]) # done: settled
+ self.assertEqual(html.count(' and never a handler: clicking it can
+ do nothing, so it cannot quietly edit the file."""
+ html = self.render(self.SRC + "\n- [X] upper case counts as ticked\n")
+ self.assertNotIn("]*)>", html)[-1])
+
+ def test_a_bracket_that_is_not_a_checkbox_is_left_alone(self):
+ items = self.items(self.render("- [see the spec](../ref.md) explains it\n"))
+ self.assertEqual(items, ["see the spec explains it"])
+ self.assertIn('href="../ref.md"', self.render(
+ "- [see the spec](../ref.md) explains it\n"))
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
+class NestingTests(RendererCase):
+ """Children indent under their parent instead of flattening beside it."""
+
+ def test_a_nested_list_is_a_child_of_its_parent_item(self):
+ html = self.render(
+ "- parent one\n"
+ " - child a\n"
+ " - child b\n"
+ "- parent two\n")
+ self.assertEqual(self.items(html), ["parent one", "parent two"])
+ self.assertRegex(html, r"parent one
", html)
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
+class ReflowTests(RendererCase):
+ """Prose wraps to the drawer, not to the author's editor."""
+
+ def test_a_paragraph_has_no_hard_break(self):
+ html = self.render(
+ "The renderer is line-based and the task files are\n"
+ "hard-wrapped, so almost every list on the board comes\n"
+ "out wrong.\n")
+ self.assertNotIn(" ", html)
+ self.assertIn("task files are hard-wrapped", html)
+
+ def test_a_blockquote_reflows_too(self):
+ html = self.render("> a quoted line\n> and its continuation\n"
+ .replace(">", ">"))
+ self.assertNotIn(" ", html)
+ self.assertIn("a quoted line and its continuation", html)
+
+ def test_no_br_survives_anywhere_in_the_corpus(self):
+ """Every card on the board plus AGENTS.md: the author's wrap column
+ must not reach the browser."""
+ docs = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
+ self.assertGreater(len(docs), 5, "no task files found to render")
+ for doc in docs:
+ with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
+ self.assertNotIn(" ", self.render(
+ doc.read_text(encoding="utf-8")))
+
+
+def source_bullets(src: str) -> int:
+ """How many logical list items a document contains, counted from the
+ source the way a reader counts them: markers only, fences skipped, and
+ only in blocks that actually open with one."""
+ marker = re.compile(r"^[ \t]*(?:[-*]|\d+\.)[ \t]+")
+ total, fence = 0, False
+ for block in re.split(r"\n{2,}", src):
+ ticks = block.count("```")
+ if fence or block.startswith("```"):
+ fence = (ticks % 2 == 0) if fence else (ticks % 2 == 1)
+ continue
+ lines = block.rstrip().split("\n")
+ if len(lines) >= 2 and re.match(r"^\s*\|.*\|\s*$", lines[0]) \
+ and re.match(r"^\s*\|[\s:|-]+\|\s*$", lines[1]):
+ continue # a table, not a list
+ if marker.match(lines[0]):
+ total += sum(1 for l in lines if marker.match(l))
+ return total
+
+
+@unittest.skipUnless(NODE, "node is needed to run the page's own md()")
+class CorpusTests(RendererCase):
+ """The whole board, not a fixture: one bullet per marker, no more."""
+
+ def docs(self) -> list[Path]:
+ found = sorted(ROOT.glob("tasks/*/*.md")) + [ROOT / "AGENTS.md"]
+ self.assertGreater(len(found), 5, "no task files found to render")
+ return found
+
+ def test_every_document_renders_one_bullet_per_marker(self):
+ """Before the fix a hard-wrapped item produced a bullet per source
+ line; this is the acceptance criterion applied to every card."""
+ for doc in self.docs():
+ with self.subTest(doc=doc.relative_to(ROOT).as_posix()):
+ src = doc.read_text(encoding="utf-8")
+ self.assertEqual(self.render(src).count("
'), html.count("
(.*?)", html, re.S).group(1)
+ self.assertEqual(body.rstrip("\n").split("\n"), [
+ ".task-manager/",
+ "├── AGENTS.md ← This file",
+ "│ ├── VERSION, board.py",
+ "└── manager/",
+ ])
+
+ def test_a_fence_spanning_blank_lines_still_closes(self):
+ """The fence state machine spans blocks — a blank line inside a
+ fence must not end it, and a bullet inside must stay literal."""
+ html = self.render("```\nfirst\n\n- not a bullet\n```\n\nafter\n")
+ self.assertEqual(html.count("
"), 1)
+ self.assertNotIn("
", html)
+ self.assertIn("
after
", html)
+
+ def test_a_table_after_a_list_is_still_a_table(self):
+ """Card 30's wrong/right table is the live case."""
+ html = self.render(
+ "- a bullet that wraps\n onto a second line\n\n"
+ "| Turn 1 says | bench actually |\n| --- | --- |\n"
+ "| `bench.toml` | `manager/local/.env` |\n")
+ self.assertIn("
", html)
+ self.assertIn("
Turn 1 says
", html)
+ self.assertIn("
bench.toml
", html)
+ self.assertEqual(html.count("
What to build", html)
+ self.assertIn("", html)
+ self.assertIn("
item
", html)
+
+ def test_html_in_the_source_is_still_escaped(self):
+ html = self.render("- an item with in it\n")
+ self.assertNotIn("